import type { CategoryRenderSettings } from './PageRenderer'; import { buildCanonicalPostPath } 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; skipContextSetup?: boolean; maxPostsPerPage?: number; allowEmptyArchiveRender?: boolean; } export function createGenerationRouteRenderer(params: { renderWithContext: (pathname: string, context: RenderContext) => Promise; context: RenderContext; }): (pathname: string) => Promise { const routeHtmlCache = new Map>(); return async (pathname: string): Promise => { 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; categorySettings?: Record; menu?: MenuDocument; }; maxPostsPerPage: number; publishedPostsForLookup: PostData[]; engines: { postEngine: { getPostsFiltered: (filter: Parameters[1] extends never ? never : any) => Promise; getPublishedVersion: (postId: string) => Promise; findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise; getPost: (postId: string) => Promise; hasPublishedVersion: (postId: string) => Promise; setProjectContext: (projectId: string, dataDir?: string) => void; }; mediaEngine: { getAllMedia: () => Promise; setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void; }; postMediaEngine: { setProjectContext: (projectId: string) => void; getLinkedMediaForPost: (postId: string) => Promise; getLinkedMediaDataForPost: (postId: string) => Promise; }; }; }): (pathname: string) => Promise { 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, }; params.engines.postEngine.setProjectContext(projectContext.projectId, projectContext.dataDir); params.engines.mediaEngine.setProjectContext?.(projectContext.projectId, projectContext.dataDir, projectContext.dataDir); params.engines.postMediaEngine.setProjectContext(projectContext.projectId); const mediaItemsPromiseCache = new Map>(); const postsByFilterPromiseCache = new Map>(); const publishedSnapshotByIdPromiseCache = new Map>(); const publishedBySlugIndex = new Map(); 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 (value instanceof Date) { return value.toISOString(); } if (Array.isArray(value)) { return value.map((entry) => normalizeValue(entry)); } if (value && typeof value === 'object') { const sortedEntries = Object.entries(value as Record) .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, }); const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map; canonicalMediaPathBySourcePath: Map }> = (async () => { const canonicalPostPathBySlug = new Map(); for (const post of params.publishedPostsForLookup) { canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post)); } const canonicalMediaPathBySourcePath = new Map(); const mediaItems = await cachedMediaEngine.getAllMedia(); for (const media of mediaItems as Array<{ createdAt: Date; filename: string; originalName: string }>) { const year = media.createdAt.getFullYear(); const month = String(media.createdAt.getMonth() + 1).padStart(2, '0'); const canonicalPath = `/media/${year}/${month}/${media.filename}`; const originalNameKey = `media/${year}/${month}/${media.originalName}`.toLowerCase(); const filenameKey = `media/${year}/${month}/${media.filename}`.toLowerCase(); canonicalMediaPathBySourcePath.set(originalNameKey, canonicalPath); canonicalMediaPathBySourcePath.set(filenameKey, canonicalPath); } return { canonicalPostPathBySlug, canonicalMediaPathBySourcePath, }; })(); return createGenerationRouteRenderer({ renderWithContext: async (pathname, context) => previewServer.renderRouteForContext(pathname, { ...context, htmlRewriteContext: await htmlRewriteContextPromise, }), context: { projectContext, metadata, menu, skipContextSetup: true, maxPostsPerPage: params.maxPostsPerPage, allowEmptyArchiveRender: true, }, }); }