chore: more refactorings
This commit is contained in:
File diff suppressed because it is too large
Load Diff
103
src/main/engine/BlogGenerationOutputService.ts
Normal file
103
src/main/engine/BlogGenerationOutputService.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { getGeneratedFileHash, setGeneratedFileHash } from '../database/generatedFileHashStore';
|
||||||
|
import { PREVIEW_ASSETS, PREVIEW_IMAGE_ASSETS } from './PageRenderer';
|
||||||
|
|
||||||
|
export function normalizeGeneratedUrlPath(urlPath: string): string {
|
||||||
|
const trimmed = (urlPath || '').trim();
|
||||||
|
if (!trimmed || trimmed === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const noQuery = trimmed.split('?')[0]?.split('#')[0] ?? '';
|
||||||
|
const withoutSlashes = noQuery.replace(/^\/+|\/+$/g, '');
|
||||||
|
return withoutSlashes ? `/${withoutSlashes}` : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urlPathToHtmlIndexPath(htmlDir: string, urlPath: string): string {
|
||||||
|
const normalizedPath = normalizeGeneratedUrlPath(urlPath);
|
||||||
|
if (normalizedPath === '/') {
|
||||||
|
return path.join(htmlDir, 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(htmlDir, normalizedPath.slice(1), 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeContentHash(content: string): string {
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeFileIfHashChanged(params: {
|
||||||
|
projectId: string;
|
||||||
|
filePath: string;
|
||||||
|
relativePath: string;
|
||||||
|
content: string;
|
||||||
|
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
|
||||||
|
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
|
||||||
|
computeHash?: (content: string) => string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const getHash = params.getGeneratedFileHash ?? getGeneratedFileHash;
|
||||||
|
const setHash = params.setGeneratedFileHash ?? setGeneratedFileHash;
|
||||||
|
const hashFn = params.computeHash ?? computeContentHash;
|
||||||
|
|
||||||
|
const hash = hashFn(params.content);
|
||||||
|
const previousHash = await getHash(params.projectId, params.relativePath);
|
||||||
|
if (previousHash === hash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(params.filePath, params.content, 'utf-8');
|
||||||
|
await setHash(params.projectId, params.relativePath, hash);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeHtmlPage(params: {
|
||||||
|
projectId: string;
|
||||||
|
htmlDir: string;
|
||||||
|
urlPath: string;
|
||||||
|
content: string;
|
||||||
|
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
|
||||||
|
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
|
||||||
|
computeHash?: (content: string) => string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const normalizedPath = params.urlPath.replace(/^\//, '');
|
||||||
|
const filePath = normalizedPath
|
||||||
|
? path.join(params.htmlDir, normalizedPath, 'index.html')
|
||||||
|
: path.join(params.htmlDir, 'index.html');
|
||||||
|
const relativePath = normalizedPath ? `${normalizedPath}/index.html` : 'index.html';
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
return writeFileIfHashChanged({
|
||||||
|
projectId: params.projectId,
|
||||||
|
filePath,
|
||||||
|
relativePath,
|
||||||
|
content: params.content,
|
||||||
|
getGeneratedFileHash: params.getGeneratedFileHash,
|
||||||
|
setGeneratedFileHash: params.setGeneratedFileHash,
|
||||||
|
computeHash: params.computeHash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyPreviewAssets(htmlDir: string): Promise<void> {
|
||||||
|
const assetsDir = path.join(htmlDir, 'assets');
|
||||||
|
const imagesDir = path.join(htmlDir, 'images');
|
||||||
|
await fs.mkdir(assetsDir, { recursive: true });
|
||||||
|
await fs.mkdir(imagesDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) {
|
||||||
|
const destPath = path.join(assetsDir, filename);
|
||||||
|
const content = definition.sourceText !== undefined
|
||||||
|
? Buffer.from(definition.sourceText, 'utf-8')
|
||||||
|
: await fs.readFile(require.resolve(definition.modulePath as string));
|
||||||
|
await fs.writeFile(destPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [filename, definition] of Object.entries(PREVIEW_IMAGE_ASSETS)) {
|
||||||
|
const sourcePath = require.resolve(definition.modulePath);
|
||||||
|
const destPath = path.join(imagesDir, filename);
|
||||||
|
const content = await fs.readFile(sourcePath);
|
||||||
|
await fs.writeFile(destPath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/main/engine/GenerationPostIndexService.ts
Normal file
129
src/main/engine/GenerationPostIndexService.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { PostData } from './PostEngine';
|
||||||
|
import type { GenerationPostIndexLike } from './GenerationSitemapFeedService';
|
||||||
|
|
||||||
|
export type GenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date';
|
||||||
|
|
||||||
|
export type GenerationPostIndex = GenerationPostIndexLike;
|
||||||
|
|
||||||
|
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||||
|
if (post.createdAt instanceof Date) {
|
||||||
|
return post.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(post.createdAt);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGenerationPostIndex(posts: PostData[]): GenerationPostIndex {
|
||||||
|
const postsByCategory = new Map<string, PostData[]>();
|
||||||
|
const postsByTag = new Map<string, PostData[]>();
|
||||||
|
const postsByYear = new Map<number, PostData[]>();
|
||||||
|
const postsByYearMonth = new Map<string, PostData[]>();
|
||||||
|
const postsByYearMonthDay = new Map<string, PostData[]>();
|
||||||
|
|
||||||
|
const append = <TKey extends string | number>(target: Map<TKey, PostData[]>, key: TKey, post: PostData) => {
|
||||||
|
const existing = target.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(post);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.set(key, [post]);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
for (const category of post.categories || []) {
|
||||||
|
append(postsByCategory, category, post);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of post.tags || []) {
|
||||||
|
append(postsByTag, tag, post);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
const ym = `${year}/${month}`;
|
||||||
|
const ymd = `${year}/${month}/${day}`;
|
||||||
|
|
||||||
|
append(postsByYear, year, post);
|
||||||
|
append(postsByYearMonth, ym, post);
|
||||||
|
append(postsByYearMonthDay, ymd, post);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
postsByCategory,
|
||||||
|
postsByTag,
|
||||||
|
postsByYear,
|
||||||
|
postsByYearMonth,
|
||||||
|
postsByYearMonthDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPaginatedPages(totalPosts: number, maxPostsPerPage: number): number {
|
||||||
|
if (totalPosts <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.ceil(totalPosts / maxPostsPerPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateGenerationUnitsBySection(params: {
|
||||||
|
posts: PostData[];
|
||||||
|
allCategories: Set<string>;
|
||||||
|
allTags: Set<string>;
|
||||||
|
yearsMap: Map<number, Date>;
|
||||||
|
yearMonthsMap: Map<string, Date>;
|
||||||
|
yearMonthDaysMap: Map<string, Date>;
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
postIndex?: GenerationPostIndex;
|
||||||
|
}): Record<GenerationSection, number> {
|
||||||
|
const {
|
||||||
|
posts,
|
||||||
|
allCategories,
|
||||||
|
allTags,
|
||||||
|
yearsMap,
|
||||||
|
yearMonthsMap,
|
||||||
|
yearMonthDaysMap,
|
||||||
|
maxPostsPerPage,
|
||||||
|
postIndex,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const index = postIndex ?? buildGenerationPostIndex(posts);
|
||||||
|
const rootPages = countPaginatedPages(posts.length, maxPostsPerPage);
|
||||||
|
const pageRoutes = index.postsByCategory.get('page')?.length ?? 0;
|
||||||
|
|
||||||
|
const categoryPages = Array.from(allCategories).reduce((sum, category) => {
|
||||||
|
const count = index.postsByCategory.get(category)?.length ?? 0;
|
||||||
|
return sum + countPaginatedPages(count, maxPostsPerPage);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const tagPages = Array.from(allTags).reduce((sum, tag) => {
|
||||||
|
const count = index.postsByTag.get(tag)?.length ?? 0;
|
||||||
|
return sum + countPaginatedPages(count, maxPostsPerPage);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
let datePages = 0;
|
||||||
|
|
||||||
|
for (const [year] of yearsMap) {
|
||||||
|
const count = index.postsByYear.get(year)?.length ?? 0;
|
||||||
|
datePages += countPaginatedPages(count, maxPostsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ym] of yearMonthsMap) {
|
||||||
|
const count = index.postsByYearMonth.get(ym)?.length ?? 0;
|
||||||
|
datePages += countPaginatedPages(count, maxPostsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ymd] of yearMonthDaysMap) {
|
||||||
|
const count = index.postsByYearMonthDay.get(ymd)?.length ?? 0;
|
||||||
|
datePages += countPaginatedPages(count, maxPostsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
core: 4 + rootPages + pageRoutes,
|
||||||
|
single: posts.length,
|
||||||
|
category: categoryPages,
|
||||||
|
tag: tagPages,
|
||||||
|
date: datePages,
|
||||||
|
};
|
||||||
|
}
|
||||||
213
src/main/engine/GenerationRouteRendererFactory.ts
Normal file
213
src/main/engine/GenerationRouteRendererFactory.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import type { CategoryRenderSettings } from './PageRenderer';
|
||||||
|
import type { MenuDocument } from './MenuEngine';
|
||||||
|
import type { ProjectMetadata } from './MetaEngine';
|
||||||
|
import type { PostData } from './PostEngine';
|
||||||
|
import type { PicoThemeName } from '../shared/picoThemes';
|
||||||
|
import type { CategoryMetadata } from './BlogGenerationEngine';
|
||||||
|
import { PreviewServer } from './PreviewServer';
|
||||||
|
|
||||||
|
interface RenderContext {
|
||||||
|
projectContext: {
|
||||||
|
projectId: string;
|
||||||
|
dataDir: string;
|
||||||
|
projectName: string;
|
||||||
|
projectDescription?: string;
|
||||||
|
};
|
||||||
|
metadata?: ProjectMetadata | null;
|
||||||
|
menu?: MenuDocument;
|
||||||
|
maxPostsPerPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGenerationRouteRenderer(params: {
|
||||||
|
renderWithContext: (pathname: string, context: RenderContext) => Promise<string | null>;
|
||||||
|
context: RenderContext;
|
||||||
|
}): (pathname: string) => Promise<string | null> {
|
||||||
|
const routeHtmlCache = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
return async (pathname: string): Promise<string | null> => {
|
||||||
|
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
|
||||||
|
const cached = routeHtmlCache.get(normalizedPathname);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = params.renderWithContext(normalizedPathname, params.context);
|
||||||
|
routeHtmlCache.set(normalizedPathname, promise);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||||
|
options: {
|
||||||
|
projectId: string;
|
||||||
|
dataDir: string;
|
||||||
|
projectName: string;
|
||||||
|
projectDescription?: string;
|
||||||
|
language?: string;
|
||||||
|
picoTheme?: PicoThemeName;
|
||||||
|
categoryMetadata?: Record<string, CategoryMetadata>;
|
||||||
|
categorySettings?: Record<string, CategoryRenderSettings>;
|
||||||
|
menu?: MenuDocument;
|
||||||
|
};
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
publishedPostsForLookup: PostData[];
|
||||||
|
engines: {
|
||||||
|
postEngine: {
|
||||||
|
getPostsFiltered: (filter: Parameters<PreviewServer['renderRouteForContext']>[1] extends never ? never : any) => Promise<PostData[]>;
|
||||||
|
getPublishedVersion: (postId: string) => Promise<PostData | null>;
|
||||||
|
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
||||||
|
getPost: (postId: string) => Promise<PostData | null>;
|
||||||
|
hasPublishedVersion: (postId: string) => Promise<boolean>;
|
||||||
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||||
|
};
|
||||||
|
mediaEngine: {
|
||||||
|
getAllMedia: () => Promise<unknown[]>;
|
||||||
|
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||||
|
};
|
||||||
|
postMediaEngine: {
|
||||||
|
setProjectContext: (projectId: string) => void;
|
||||||
|
getLinkedMediaForPost: (postId: string) => Promise<unknown[]>;
|
||||||
|
getLinkedMediaDataForPost: (postId: string) => Promise<unknown[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}): (pathname: string) => Promise<string | null> {
|
||||||
|
const metadata: ProjectMetadata = {
|
||||||
|
name: params.options.projectName,
|
||||||
|
description: params.options.projectDescription,
|
||||||
|
mainLanguage: params.options.language,
|
||||||
|
maxPostsPerPage: params.maxPostsPerPage,
|
||||||
|
picoTheme: params.options.picoTheme,
|
||||||
|
categoryMetadata: params.options.categoryMetadata,
|
||||||
|
categorySettings: params.options.categorySettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = params.options.menu ?? { items: [] };
|
||||||
|
const projectContext = {
|
||||||
|
projectId: params.options.projectId,
|
||||||
|
dataDir: params.options.dataDir,
|
||||||
|
projectName: params.options.projectName,
|
||||||
|
projectDescription: params.options.projectDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaItemsPromiseCache = new Map<string, Promise<unknown[]>>();
|
||||||
|
const postsByFilterPromiseCache = new Map<string, Promise<PostData[]>>();
|
||||||
|
const publishedSnapshotByIdPromiseCache = new Map<string, Promise<PostData | null>>();
|
||||||
|
const publishedBySlugIndex = new Map<string, PostData[]>();
|
||||||
|
|
||||||
|
for (const post of params.publishedPostsForLookup) {
|
||||||
|
const existing = publishedBySlugIndex.get(post.slug);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(post);
|
||||||
|
} else {
|
||||||
|
publishedBySlugIndex.set(post.slug, [post]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeFilter = (filter: unknown): string => {
|
||||||
|
const normalizeValue = (value: unknown): unknown => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => normalizeValue(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const sortedEntries = Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, nestedValue]) => [key, normalizeValue(nestedValue)] as const);
|
||||||
|
return Object.fromEntries(sortedEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(normalizeValue(filter));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedPostEngine = {
|
||||||
|
getPostsFiltered: (filter: unknown) => {
|
||||||
|
const cacheKey = serializeFilter(filter);
|
||||||
|
const cached = postsByFilterPromiseCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = params.engines.postEngine.getPostsFiltered(filter as never);
|
||||||
|
postsByFilterPromiseCache.set(cacheKey, promise);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
getPublishedVersion: (postId: string) => {
|
||||||
|
const cached = publishedSnapshotByIdPromiseCache.get(postId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = params.engines.postEngine.getPublishedVersion(postId);
|
||||||
|
publishedSnapshotByIdPromiseCache.set(postId, promise);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
findPublishedBySlug: async (slug: string, dateFilter?: { year: number; month: number }) => {
|
||||||
|
const candidates = publishedBySlugIndex.get(slug);
|
||||||
|
if (!candidates || candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dateFilter) {
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = candidates.find((candidate) => {
|
||||||
|
const createdAt = candidate.createdAt;
|
||||||
|
return createdAt.getFullYear() === dateFilter.year
|
||||||
|
&& createdAt.getMonth() === dateFilter.month;
|
||||||
|
});
|
||||||
|
|
||||||
|
return match ?? null;
|
||||||
|
},
|
||||||
|
getPost: (postId: string) => params.engines.postEngine.getPost(postId),
|
||||||
|
hasPublishedVersion: (postId: string) => params.engines.postEngine.hasPublishedVersion(postId),
|
||||||
|
setProjectContext: (projectId: string, dataDir?: string) => {
|
||||||
|
params.engines.postEngine.setProjectContext(projectId, dataDir);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedMediaEngine = {
|
||||||
|
getAllMedia: () => {
|
||||||
|
const cacheKey = `${params.options.projectId}:${params.options.dataDir ?? ''}`;
|
||||||
|
const cached = mediaItemsPromiseCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = params.engines.mediaEngine.getAllMedia();
|
||||||
|
mediaItemsPromiseCache.set(cacheKey, promise);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
setProjectContext: (projectId: string, dataDir?: string, internalDir?: string) => {
|
||||||
|
params.engines.mediaEngine.setProjectContext?.(projectId, dataDir, internalDir);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewServer = new PreviewServer({
|
||||||
|
postEngine: cachedPostEngine as never,
|
||||||
|
mediaEngine: cachedMediaEngine as never,
|
||||||
|
postMediaEngine: params.engines.postMediaEngine as never,
|
||||||
|
settingsEngine: {
|
||||||
|
setProjectContext: () => {},
|
||||||
|
getProjectMetadata: async () => metadata,
|
||||||
|
},
|
||||||
|
menuEngine: {
|
||||||
|
setProjectContext: () => {},
|
||||||
|
getMenu: async () => menu,
|
||||||
|
},
|
||||||
|
getActiveProjectContext: async () => projectContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createGenerationRouteRenderer({
|
||||||
|
renderWithContext: (pathname, context) => previewServer.renderRouteForContext(pathname, context),
|
||||||
|
context: {
|
||||||
|
projectContext,
|
||||||
|
metadata,
|
||||||
|
menu,
|
||||||
|
maxPostsPerPage: params.maxPostsPerPage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
257
src/main/engine/RoutePageGenerationService.ts
Normal file
257
src/main/engine/RoutePageGenerationService.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import type { PostData } from './PostEngine';
|
||||||
|
|
||||||
|
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||||
|
if (post.createdAt instanceof Date) {
|
||||||
|
return post.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(post.createdAt);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRequiredRoute(
|
||||||
|
renderRoute: (pathname: string) => Promise<string | null>,
|
||||||
|
pathname: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const html = await renderRoute(pathname);
|
||||||
|
if (html !== null) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Shared route renderer returned null for required path: ${pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseParams {
|
||||||
|
projectId: string;
|
||||||
|
renderRoute: (pathname: string) => Promise<string | null>;
|
||||||
|
writePage: (projectId: string, urlPath: string, content: string) => Promise<boolean>;
|
||||||
|
onPageGenerated: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateRootPages(params: BaseParams & {
|
||||||
|
posts: PostData[];
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(params.posts.length / params.maxPostsPerPage));
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (let page = 1; page <= totalPages; page++) {
|
||||||
|
const offset = (page - 1) * params.maxPostsPerPage;
|
||||||
|
const pagePosts = params.posts.slice(offset, offset + params.maxPostsPerPage);
|
||||||
|
if (pagePosts.length === 0) break;
|
||||||
|
|
||||||
|
const routePath = page === 1 ? '/' : `/page/${page}`;
|
||||||
|
const html = await renderRequiredRoute(params.renderRoute, routePath);
|
||||||
|
const urlPath = page === 1 ? '' : `page/${page}`;
|
||||||
|
await params.writePage(params.projectId, urlPath, html);
|
||||||
|
count++;
|
||||||
|
params.onPageGenerated(urlPath ? `Generated /${urlPath}` : 'Generated /');
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePageRoutes(params: BaseParams & {
|
||||||
|
posts: PostData[];
|
||||||
|
}): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
const pagePosts = params.posts.filter((post) => (post.categories || []).includes('page'));
|
||||||
|
|
||||||
|
for (const post of pagePosts) {
|
||||||
|
const routePath = `/${post.slug}`;
|
||||||
|
const html = await renderRequiredRoute(params.renderRoute, routePath);
|
||||||
|
await params.writePage(params.projectId, post.slug, html);
|
||||||
|
count++;
|
||||||
|
params.onPageGenerated(`Generated /${post.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSinglePostPages(params: BaseParams & {
|
||||||
|
posts: PostData[];
|
||||||
|
}): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const post of params.posts) {
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
const urlPath = `${year}/${month}/${day}/${post.slug}`;
|
||||||
|
const html = await renderRequiredRoute(params.renderRoute, `/${urlPath}`);
|
||||||
|
await params.writePage(params.projectId, urlPath, html);
|
||||||
|
count++;
|
||||||
|
params.onPageGenerated(`Generated /${urlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePaginatedListPages(params: BaseParams & {
|
||||||
|
posts: PostData[];
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
urlPrefix: string;
|
||||||
|
}): Promise<number> {
|
||||||
|
if (params.posts.length === 0) return 0;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(params.posts.length / params.maxPostsPerPage));
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (let page = 1; page <= totalPages; page++) {
|
||||||
|
const offset = (page - 1) * params.maxPostsPerPage;
|
||||||
|
const pagePosts = params.posts.slice(offset, offset + params.maxPostsPerPage);
|
||||||
|
if (pagePosts.length === 0) break;
|
||||||
|
|
||||||
|
const routePath = page === 1 ? `/${params.urlPrefix}` : `/${params.urlPrefix}/page/${page}`;
|
||||||
|
const html = await renderRequiredRoute(params.renderRoute, routePath);
|
||||||
|
const urlPath = page === 1 ? params.urlPrefix : `${params.urlPrefix}/page/${page}`;
|
||||||
|
await params.writePage(params.projectId, urlPath, html);
|
||||||
|
count++;
|
||||||
|
params.onPageGenerated(`Generated /${urlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCategoryPages(params: BaseParams & {
|
||||||
|
posts: PostData[];
|
||||||
|
allCategories: Set<string>;
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
postsByCategory?: Map<string, PostData[]>;
|
||||||
|
}): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const category of Array.from(params.allCategories).sort()) {
|
||||||
|
const categoryPosts = params.postsByCategory?.get(category) ?? params.posts.filter((post) => (post.categories || []).includes(category));
|
||||||
|
if (categoryPosts.length === 0) continue;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / params.maxPostsPerPage));
|
||||||
|
const encodedCategory = encodeURIComponent(category);
|
||||||
|
|
||||||
|
for (let page = 1; page <= totalPages; page++) {
|
||||||
|
const offset = (page - 1) * params.maxPostsPerPage;
|
||||||
|
const pagePosts = categoryPosts.slice(offset, offset + params.maxPostsPerPage);
|
||||||
|
if (pagePosts.length === 0) break;
|
||||||
|
|
||||||
|
const routePath = page === 1
|
||||||
|
? `/category/${encodedCategory}`
|
||||||
|
: `/category/${encodedCategory}/page/${page}`;
|
||||||
|
const html = await renderRequiredRoute(params.renderRoute, routePath);
|
||||||
|
const urlPath = page === 1
|
||||||
|
? `category/${encodedCategory}`
|
||||||
|
: `category/${encodedCategory}/page/${page}`;
|
||||||
|
await params.writePage(params.projectId, urlPath, html);
|
||||||
|
count++;
|
||||||
|
params.onPageGenerated(`Generated /${urlPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTagPages(params: BaseParams & {
|
||||||
|
posts: PostData[];
|
||||||
|
allTags: Set<string>;
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
postsByTag?: Map<string, PostData[]>;
|
||||||
|
}): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const tag of Array.from(params.allTags).sort()) {
|
||||||
|
const tagPosts = params.postsByTag?.get(tag) ?? params.posts.filter((post) => (post.tags || []).includes(tag));
|
||||||
|
if (tagPosts.length === 0) continue;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(tagPosts.length / params.maxPostsPerPage));
|
||||||
|
const encodedTag = encodeURIComponent(tag);
|
||||||
|
|
||||||
|
for (let page = 1; page <= totalPages; page++) {
|
||||||
|
const offset = (page - 1) * params.maxPostsPerPage;
|
||||||
|
const pagePosts = tagPosts.slice(offset, offset + params.maxPostsPerPage);
|
||||||
|
if (pagePosts.length === 0) break;
|
||||||
|
|
||||||
|
const routePath = page === 1
|
||||||
|
? `/tag/${encodedTag}`
|
||||||
|
: `/tag/${encodedTag}/page/${page}`;
|
||||||
|
const html = await renderRequiredRoute(params.renderRoute, routePath);
|
||||||
|
const urlPath = page === 1
|
||||||
|
? `tag/${encodedTag}`
|
||||||
|
: `tag/${encodedTag}/page/${page}`;
|
||||||
|
await params.writePage(params.projectId, urlPath, html);
|
||||||
|
count++;
|
||||||
|
params.onPageGenerated(`Generated /${urlPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDateArchivePages(params: BaseParams & {
|
||||||
|
posts: PostData[];
|
||||||
|
yearsMap: Map<number, Date>;
|
||||||
|
yearMonthsMap: Map<string, Date>;
|
||||||
|
yearMonthDaysMap: Map<string, Date>;
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
postsByYear?: Map<number, PostData[]>;
|
||||||
|
postsByYearMonth?: Map<string, PostData[]>;
|
||||||
|
postsByYearMonthDay?: Map<string, PostData[]>;
|
||||||
|
}): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const [year] of Array.from(params.yearsMap.entries()).sort((a, b) => b[0] - a[0])) {
|
||||||
|
const yearPosts = params.postsByYear?.get(year) ?? params.posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year);
|
||||||
|
count += await generatePaginatedListPages({
|
||||||
|
projectId: params.projectId,
|
||||||
|
posts: yearPosts,
|
||||||
|
maxPostsPerPage: params.maxPostsPerPage,
|
||||||
|
renderRoute: params.renderRoute,
|
||||||
|
writePage: params.writePage,
|
||||||
|
onPageGenerated: params.onPageGenerated,
|
||||||
|
urlPrefix: `${year}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ym] of Array.from(params.yearMonthsMap.entries()).sort().reverse()) {
|
||||||
|
const [yearStr, monthStr] = ym.split('/');
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
const monthPosts = params.postsByYearMonth?.get(ym) ?? params.posts.filter((post) => {
|
||||||
|
const d = resolvePostCreatedAt(post);
|
||||||
|
return d.getFullYear() === year && (d.getMonth() + 1) === month;
|
||||||
|
});
|
||||||
|
|
||||||
|
count += await generatePaginatedListPages({
|
||||||
|
projectId: params.projectId,
|
||||||
|
posts: monthPosts,
|
||||||
|
maxPostsPerPage: params.maxPostsPerPage,
|
||||||
|
renderRoute: params.renderRoute,
|
||||||
|
writePage: params.writePage,
|
||||||
|
onPageGenerated: params.onPageGenerated,
|
||||||
|
urlPrefix: ym,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ymd] of Array.from(params.yearMonthDaysMap.entries()).sort().reverse()) {
|
||||||
|
const [yearStr, monthStr, dayStr] = ymd.split('/');
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
const day = Number(dayStr);
|
||||||
|
const dayPosts = params.postsByYearMonthDay?.get(ymd) ?? params.posts.filter((post) => {
|
||||||
|
const d = resolvePostCreatedAt(post);
|
||||||
|
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
|
||||||
|
});
|
||||||
|
|
||||||
|
count += await generatePaginatedListPages({
|
||||||
|
projectId: params.projectId,
|
||||||
|
posts: dayPosts,
|
||||||
|
maxPostsPerPage: params.maxPostsPerPage,
|
||||||
|
renderRoute: params.renderRoute,
|
||||||
|
writePage: params.writePage,
|
||||||
|
onPageGenerated: params.onPageGenerated,
|
||||||
|
urlPrefix: ymd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
75
tests/engine/BlogGenerationOutputService.test.ts
Normal file
75
tests/engine/BlogGenerationOutputService.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { mkdir, readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizeGeneratedUrlPath,
|
||||||
|
urlPathToHtmlIndexPath,
|
||||||
|
writeFileIfHashChanged,
|
||||||
|
writeHtmlPage,
|
||||||
|
} from '../../src/main/engine/BlogGenerationOutputService';
|
||||||
|
|
||||||
|
function makeTempName(): string {
|
||||||
|
return `bds-generation-output-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BlogGenerationOutputService', () => {
|
||||||
|
it('normalizes URL paths and maps them to html index file paths', () => {
|
||||||
|
expect(normalizeGeneratedUrlPath('')).toBe('/');
|
||||||
|
expect(normalizeGeneratedUrlPath('/a/b/')).toBe('/a/b');
|
||||||
|
expect(urlPathToHtmlIndexPath('/tmp/html', '/')).toBe('/tmp/html/index.html');
|
||||||
|
expect(urlPathToHtmlIndexPath('/tmp/html', '/a/b')).toBe('/tmp/html/a/b/index.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes only when generated hash changes', async () => {
|
||||||
|
const tempRoot = path.join('/tmp', makeTempName());
|
||||||
|
await mkdir(tempRoot, { recursive: true });
|
||||||
|
const filePath = path.join(tempRoot, 'a.txt');
|
||||||
|
|
||||||
|
const getHash = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('same-hash');
|
||||||
|
const setHash = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const hashFn = vi.fn().mockReturnValue('same-hash');
|
||||||
|
|
||||||
|
const changed = await writeFileIfHashChanged({
|
||||||
|
projectId: 'p',
|
||||||
|
filePath,
|
||||||
|
relativePath: 'a.txt',
|
||||||
|
content: 'hello',
|
||||||
|
getGeneratedFileHash: getHash,
|
||||||
|
setGeneratedFileHash: setHash,
|
||||||
|
computeHash: hashFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unchanged = await writeFileIfHashChanged({
|
||||||
|
projectId: 'p',
|
||||||
|
filePath,
|
||||||
|
relativePath: 'a.txt',
|
||||||
|
content: 'hello',
|
||||||
|
getGeneratedFileHash: getHash,
|
||||||
|
setGeneratedFileHash: setHash,
|
||||||
|
computeHash: hashFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
expect(unchanged).toBe(false);
|
||||||
|
expect(await readFile(filePath, 'utf-8')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes html pages under index.html route directories', async () => {
|
||||||
|
const tempRoot = path.join('/tmp', makeTempName());
|
||||||
|
const htmlDir = path.join(tempRoot, 'html');
|
||||||
|
await mkdir(htmlDir, { recursive: true });
|
||||||
|
|
||||||
|
await writeHtmlPage({
|
||||||
|
projectId: 'p',
|
||||||
|
htmlDir,
|
||||||
|
urlPath: 'section/page',
|
||||||
|
content: '<html/>',
|
||||||
|
getGeneratedFileHash: async () => null,
|
||||||
|
setGeneratedFileHash: async () => undefined,
|
||||||
|
computeHash: () => 'h',
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await readFile(path.join(htmlDir, 'section', 'page', 'index.html'), 'utf-8');
|
||||||
|
expect(saved).toBe('<html/>');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/engine/GenerationPostIndexService.test.ts
Normal file
73
tests/engine/GenerationPostIndexService.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||||
|
import {
|
||||||
|
buildGenerationPostIndex,
|
||||||
|
estimateGenerationUnitsBySection,
|
||||||
|
} from '../../src/main/engine/GenerationPostIndexService';
|
||||||
|
|
||||||
|
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||||
|
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'post-1',
|
||||||
|
projectId: overrides.projectId ?? 'project',
|
||||||
|
title: overrides.title ?? 'Title',
|
||||||
|
slug: overrides.slug ?? 'title',
|
||||||
|
excerpt: overrides.excerpt,
|
||||||
|
content: overrides.content ?? 'Body',
|
||||||
|
status: overrides.status ?? 'published',
|
||||||
|
author: overrides.author,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: overrides.updatedAt ?? createdAt,
|
||||||
|
publishedAt: overrides.publishedAt,
|
||||||
|
tags: overrides.tags ?? [],
|
||||||
|
categories: overrides.categories ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GenerationPostIndexService', () => {
|
||||||
|
it('indexes posts by category tag and date partitions', () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', categories: ['news'], tags: ['t1'], createdAt: new Date('2025-01-15T00:00:00.000Z') }),
|
||||||
|
makePost({ id: '2', categories: ['page'], tags: ['t2'], createdAt: new Date('2025-02-20T00:00:00.000Z') }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const index = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
expect(index.postsByCategory.get('news')?.map((p) => p.id)).toEqual(['1']);
|
||||||
|
expect(index.postsByCategory.get('page')?.map((p) => p.id)).toEqual(['2']);
|
||||||
|
expect(index.postsByTag.get('t2')?.map((p) => p.id)).toEqual(['2']);
|
||||||
|
expect(index.postsByYear.get(2025)?.length).toBe(2);
|
||||||
|
expect(index.postsByYearMonth.get('2025/01')?.length).toBe(1);
|
||||||
|
expect(index.postsByYearMonthDay.get('2025/02/20')?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('estimates generation units per section using indexed counts', () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', categories: ['news'], tags: ['t1'], createdAt: new Date('2025-01-15T00:00:00.000Z') }),
|
||||||
|
makePost({ id: '2', categories: ['news'], tags: ['t1'], createdAt: new Date('2025-01-14T00:00:00.000Z') }),
|
||||||
|
makePost({ id: '3', categories: ['page'], tags: [], createdAt: new Date('2025-01-13T00:00:00.000Z') }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const index = buildGenerationPostIndex(posts);
|
||||||
|
const estimate = estimateGenerationUnitsBySection({
|
||||||
|
posts,
|
||||||
|
allCategories: new Set(['news', 'page']),
|
||||||
|
allTags: new Set(['t1']),
|
||||||
|
yearsMap: new Map([[2025, new Date()]]),
|
||||||
|
yearMonthsMap: new Map([['2025/01', new Date()]]),
|
||||||
|
yearMonthDaysMap: new Map([
|
||||||
|
['2025/01/15', new Date()],
|
||||||
|
['2025/01/14', new Date()],
|
||||||
|
['2025/01/13', new Date()],
|
||||||
|
]),
|
||||||
|
maxPostsPerPage: 2,
|
||||||
|
postIndex: index,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(estimate.core).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(estimate.single).toBe(3);
|
||||||
|
expect(estimate.category).toBe(2);
|
||||||
|
expect(estimate.tag).toBe(1);
|
||||||
|
expect(estimate.date).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/engine/GenerationRouteRendererFactory.test.ts
Normal file
34
tests/engine/GenerationRouteRendererFactory.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createGenerationRouteRenderer } from '../../src/main/engine/GenerationRouteRendererFactory';
|
||||||
|
|
||||||
|
describe('GenerationRouteRendererFactory', () => {
|
||||||
|
it('normalizes route keys and memoizes html rendering calls', async () => {
|
||||||
|
const renderWithContext = vi.fn().mockResolvedValue('<html>ok</html>');
|
||||||
|
|
||||||
|
const renderRoute = createGenerationRouteRenderer({
|
||||||
|
renderWithContext,
|
||||||
|
context: {
|
||||||
|
projectContext: {
|
||||||
|
projectId: 'p',
|
||||||
|
dataDir: '/tmp',
|
||||||
|
projectName: 'P',
|
||||||
|
projectDescription: 'D',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
name: 'P',
|
||||||
|
description: 'D',
|
||||||
|
},
|
||||||
|
menu: { items: [] },
|
||||||
|
maxPostsPerPage: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = await renderRoute('/foo/');
|
||||||
|
const b = await renderRoute('/foo');
|
||||||
|
|
||||||
|
expect(a).toBe('<html>ok</html>');
|
||||||
|
expect(b).toBe('<html>ok</html>');
|
||||||
|
expect(renderWithContext).toHaveBeenCalledTimes(1);
|
||||||
|
expect(renderWithContext).toHaveBeenCalledWith('/foo', expect.any(Object));
|
||||||
|
});
|
||||||
|
});
|
||||||
61
tests/engine/RoutePageGenerationService.test.ts
Normal file
61
tests/engine/RoutePageGenerationService.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||||
|
import { generateRootPages, generateSinglePostPages } from '../../src/main/engine/RoutePageGenerationService';
|
||||||
|
|
||||||
|
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||||
|
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'post-1',
|
||||||
|
projectId: overrides.projectId ?? 'project',
|
||||||
|
title: overrides.title ?? 'Title',
|
||||||
|
slug: overrides.slug ?? 'title',
|
||||||
|
excerpt: overrides.excerpt,
|
||||||
|
content: overrides.content ?? 'Body',
|
||||||
|
status: overrides.status ?? 'published',
|
||||||
|
author: overrides.author,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: overrides.updatedAt ?? createdAt,
|
||||||
|
publishedAt: overrides.publishedAt,
|
||||||
|
tags: overrides.tags ?? [],
|
||||||
|
categories: overrides.categories ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RoutePageGenerationService', () => {
|
||||||
|
it('renders root and paginated list routes', async () => {
|
||||||
|
const posts = [makePost({ id: '1' }), makePost({ id: '2' }), makePost({ id: '3' })];
|
||||||
|
const renderRoute = vi.fn().mockResolvedValue('<html>ok</html>');
|
||||||
|
const writePage = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const count = await generateRootPages({
|
||||||
|
projectId: 'p',
|
||||||
|
posts,
|
||||||
|
maxPostsPerPage: 2,
|
||||||
|
renderRoute,
|
||||||
|
writePage,
|
||||||
|
onPageGenerated: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(renderRoute).toHaveBeenCalledWith('/');
|
||||||
|
expect(renderRoute).toHaveBeenCalledWith('/page/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders canonical single post routes', async () => {
|
||||||
|
const posts = [makePost({ id: '1', slug: 'hello', createdAt: new Date('2025-01-15T10:00:00.000Z') })];
|
||||||
|
const renderRoute = vi.fn().mockResolvedValue('<html>ok</html>');
|
||||||
|
const writePage = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const count = await generateSinglePostPages({
|
||||||
|
projectId: 'p',
|
||||||
|
posts,
|
||||||
|
renderRoute,
|
||||||
|
writePage,
|
||||||
|
onPageGenerated: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(renderRoute).toHaveBeenCalledWith('/2025/01/15/hello');
|
||||||
|
expect(writePage).toHaveBeenCalledWith('p', '2025/01/15/hello', '<html>ok</html>');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user