import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { type CategoryMetadata, type ProjectMetadata } from './MetaEngine'; import { type MediaData } from './MediaEngine'; import { type MenuDocument } from './MenuEngine'; import { type PostData, type PostFilter } from './PostEngine'; import { PageRenderer, PREVIEW_ASSETS, PREVIEW_IMAGE_ASSETS, buildTemplateMenuItems, buildCanonicalPostPath, clampMaxPostsPerPage, resolvePageTitle, type CategoryRenderSettings, type HtmlRewriteContext, type MediaEngineContract, type PostMediaEngineContract, type PythonMacroRendererContract, } from './PageRenderer'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; import { renderRouteWithSharedContext } from './SharedRouteRenderer'; import { findSinglePostBySlug, loadPostsForDayPage, loadPublishedSnapshots, loadPublishedSnapshotsPage, } from './SharedSnapshotService'; import { buildCalendarArchiveData } from './GenerationSitemapFeedService'; import { loadPublishedGenerationSets } from './GenerationPostSnapshotService'; interface ActiveProjectContext { projectId: string; dataDir?: string; projectName?: string; projectDescription?: string; } interface PostEngineContract { getPostsFiltered: (filter: PostFilter) => Promise; getPost: (id: string) => Promise; hasPublishedVersion: (id: string) => Promise; getPublishedVersion: (id: string) => Promise; findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise; getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>; setProjectContext: (projectId: string, dataDir?: string) => void; } interface MetaEngineContract { getProjectMetadata: () => Promise; setProjectContext: (projectId: string, dataDir?: string) => void; isInitialized?: () => boolean; syncOnStartup?: () => Promise; } interface MenuEngineContract { getMenu: () => Promise; setProjectContext: (projectId: string, dataDir?: string) => void; } interface PreviewServerDependencies { postEngine: PostEngineContract; mediaEngine: MediaEngineContract; postMediaEngine: PostMediaEngineContract; settingsEngine: MetaEngineContract; menuEngine: MenuEngineContract; getActiveProjectContext: () => Promise; userTemplatesDir?: string; macroRenderer?: PythonMacroRendererContract; } interface SerializedTag { name?: unknown; color?: unknown; } export class PreviewServer { private readonly postEngine: PostEngineContract; private readonly mediaEngine: MediaEngineContract; private readonly postMediaEngine: PostMediaEngineContract; private readonly settingsEngine: MetaEngineContract; private readonly menuEngine: MenuEngineContract; private readonly getActiveProjectContext: () => Promise; private readonly pageRenderer: PageRenderer; private readonly tagColorByNameCache = new Map>>(); private server: Server | null = null; private port: number | null = null; constructor(dependencies?: Partial) { if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided'); if (!dependencies?.mediaEngine) throw new Error('PreviewServer: mediaEngine not provided'); if (!dependencies?.postMediaEngine) throw new Error('PreviewServer: postMediaEngine not provided'); if (!dependencies?.settingsEngine) throw new Error('PreviewServer: settingsEngine not provided'); if (!dependencies?.menuEngine) throw new Error('PreviewServer: menuEngine not provided'); if (!dependencies?.getActiveProjectContext) throw new Error('PreviewServer: getActiveProjectContext not provided'); this.postEngine = dependencies.postEngine; this.mediaEngine = dependencies.mediaEngine; this.postMediaEngine = dependencies.postMediaEngine; this.settingsEngine = dependencies.settingsEngine; this.menuEngine = dependencies.menuEngine; this.getActiveProjectContext = dependencies.getActiveProjectContext; this.pageRenderer = new PageRenderer( this.mediaEngine, this.postMediaEngine, this.postEngine, dependencies.macroRenderer ?? buildNoopMacroRenderer(), dependencies.userTemplatesDir, ); } async start(preferredPort = 0): Promise { if (this.server && this.port !== null) { return this.port; } this.server = createServer(async (req, res) => { await this.handleRequest(req, res); }); await new Promise((resolve, reject) => { if (!this.server) { reject(new Error('Preview server was not created')); return; } this.server.once('error', reject); this.server.listen(preferredPort, '127.0.0.1', () => { this.server?.off('error', reject); resolve(); }); }); const address = this.server.address(); if (!address || typeof address === 'string') { throw new Error('Failed to get preview server address'); } this.port = address.port; return this.port; } async stop(): Promise { if (!this.server) { this.port = null; return; } await new Promise((resolve, reject) => { if (!this.server) { resolve(); return; } this.server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); this.server = null; this.port = null; } getBaseUrl(): string { if (this.port === null) { throw new Error('Preview server not started'); } return `http://127.0.0.1:${this.port}`; } async renderRouteForContext( pathname: string, options: { projectContext: ActiveProjectContext; metadata?: ProjectMetadata | null; menu?: MenuDocument; htmlRewriteContext?: HtmlRewriteContext; skipContextSetup?: boolean; maxPostsPerPage?: number; requestTheme?: string | null; htmlThemeAttribute?: string; allowEmptyArchiveRender?: boolean; singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }; }, ): Promise { return renderRouteWithSharedContext(pathname, options, { postEngine: this.postEngine, mediaEngine: this.mediaEngine, postMediaEngine: this.postMediaEngine, settingsEngine: this.settingsEngine, menuEngine: this.menuEngine, resolveCategoryMetadata: (metadata) => this.resolveCategoryMetadata(metadata), resolveCategorySettings: (metadata) => this.resolveCategorySettings(metadata), resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings), buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(), resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext), resolveTagTemplateSettings: (projectContext) => this.resolveTagTemplateSettings(projectContext), pageRenderer: this.pageRenderer, postEngineForMacros: this.postEngine, loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination), loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination), loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination), findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter), getLinkedBy: this.postEngine.getLinkedBy ? (postId) => this.postEngine.getLinkedBy!(postId) : undefined, }); } private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { const remoteAddress = req.socket.remoteAddress; const isLocal = remoteAddress === '127.0.0.1' || remoteAddress === '::1' || remoteAddress === '::ffff:127.0.0.1'; if (!isLocal) { this.respond(res, 403, 'Forbidden'); return; } if ((req.method || 'GET').toUpperCase() !== 'GET') { this.respond(res, 405, 'Method Not Allowed'); return; } try { const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/'); const asset = await this.resolveAsset(pathname); if (asset) { this.respondAsset(res, asset.contentType, asset.body); return; } const context = await this.getActiveProjectContext(); this.postEngine.setProjectContext(context.projectId, context.dataDir); this.mediaEngine.setProjectContext?.(context.projectId, context.dataDir, context.dataDir); this.postMediaEngine.setProjectContext(context.projectId); this.settingsEngine.setProjectContext(context.projectId, context.dataDir); this.menuEngine.setProjectContext(context.projectId, context.dataDir); if (this.settingsEngine.isInitialized && this.settingsEngine.syncOnStartup && !this.settingsEngine.isInitialized()) { await this.settingsEngine.syncOnStartup(); } const metadata = await this.settingsEngine.getProjectMetadata(); const categoryMetadata = this.resolveCategoryMetadata(metadata); const menu = await this.menuEngine.getMenu().catch(() => ({ items: [] })); const menuItems = buildTemplateMenuItems(menu, categoryMetadata); const categorySettings = this.resolveCategorySettings(metadata); const listExcludedCategories = this.resolveListExcludedCategories(categorySettings); const language = metadata?.mainLanguage?.trim() || 'en'; const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription); const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage); const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme')); const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode')); const useDraftContent = requestUrl.searchParams.get('draft') === 'true'; const draftPostId = requestUrl.searchParams.get('postId') || undefined; const appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme); const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); const htmlRewriteContext = await this.buildHtmlRewriteContext(); if (pathname === '/calendar.json') { const calendarJson = await this.resolveCalendarJson(context.dataDir, listExcludedCategories); this.respondAsset(res, 'application/json; charset=utf-8', calendarJson); return; } if (pathname === '/__style-preview') { const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, { pageTitle, language, menuItems, picoStylesheetHref, htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined, }, categorySettings, listExcludedCategories); this.respond(res, 200, stylePreviewHtml); return; } const imageAsset = await this.resolveImageAsset(pathname); if (imageAsset) { this.respondAsset(res, imageAsset.contentType, imageAsset.body); return; } const mediaAsset = await this.resolveMediaAsset(pathname, context.dataDir); if (mediaAsset) { this.respondAsset(res, mediaAsset.contentType, mediaAsset.body); return; } const result = await this.renderRouteForContext(pathname, { projectContext: context, metadata, menu, maxPostsPerPage, requestTheme, htmlThemeAttribute: undefined, singlePostOptions: { useDraftContent, draftPostId, }, }); if (!result) { const notFoundHtml = await this.pageRenderer.renderNotFound({ page_title: '404 Not Found', language, menu_items: menuItems, pico_stylesheet_href: picoStylesheetHref, html_theme_attribute: undefined, }); this.respond(res, 404, notFoundHtml); return; } this.respond(res, 200, result); } catch (error) { console.error('[PreviewServer] Request failed:', error); this.respond(res, 500, 'Internal Server Error'); } } private async renderStylePreview( rewriteContext: HtmlRewriteContext, pageContext: { pageTitle: string; language: string; menuItems: ReturnType; picoStylesheetHref: string; htmlThemeAttribute?: string }, categorySettings: Record, listExcludedCategories: string[], ): Promise { const result = await loadPublishedSnapshotsPage(this.postEngine, { status: 'published', excludeCategories: listExcludedCategories }, { maxPostsPerPage: 10, page: 1, }); if (result.posts.length === 0) { return this.pageRenderer.renderNotFound({ page_title: pageContext.pageTitle, language: pageContext.language, menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, }); } return this.pageRenderer.renderPostList(result.posts, rewriteContext, { archiveGrouping: true, routeKind: 'date', archiveContext: { kind: 'root' }, basePathname: '/__style-preview', pagination: { page: 1, maxPostsPerPage: 10, totalPosts: result.totalPosts }, categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } private async buildHtmlRewriteContext(): Promise { const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' }); const canonicalPostPathBySlug = new Map(); for (const post of publishedPosts) { canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post)); } const canonicalMediaPathBySourcePath = new Map(); try { const mediaItems = await this.mediaEngine.getAllMedia(); for (const media of mediaItems) { 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); } } catch { // Keep media map empty if media metadata cannot be loaded. } return { canonicalPostPathBySlug, canonicalMediaPathBySourcePath, }; } private async resolveTagColorByName(projectContext: ActiveProjectContext): Promise> { const cacheKey = `${projectContext.projectId}:${projectContext.dataDir ?? ''}`; const cached = this.tagColorByNameCache.get(cacheKey); if (cached) { return cached; } const promise = this.loadTagColorByName(projectContext.dataDir); this.tagColorByNameCache.set(cacheKey, promise); return promise; } private async loadTagColorByName(dataDir?: string): Promise> { if (!dataDir) { return {}; } const tagsPath = path.join(dataDir, 'meta', 'tags.json'); try { const source = await readFile(tagsPath, 'utf-8'); const parsed = JSON.parse(source); if (!Array.isArray(parsed)) { return {}; } const colors: Record = {}; for (const rawEntry of parsed as SerializedTag[]) { const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : ''; const color = typeof rawEntry?.color === 'string' ? rawEntry.color.trim() : ''; if (!name || !color) { continue; } colors[name] = color; } return colors; } catch { return {}; } } private async resolveTagTemplateSettings(projectContext: ActiveProjectContext): Promise> { if (!projectContext.dataDir) { return {}; } const tagsPath = path.join(projectContext.dataDir, 'meta', 'tags.json'); try { const source = await readFile(tagsPath, 'utf-8'); const parsed = JSON.parse(source); if (!Array.isArray(parsed)) { return {}; } const settings: Record = {}; for (const rawEntry of parsed as SerializedTag[]) { const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : ''; const postTemplateSlug = typeof (rawEntry as Record)?.postTemplateSlug === 'string' ? ((rawEntry as Record).postTemplateSlug as string).trim() : undefined; if (!name || !postTemplateSlug) { continue; } settings[name] = { postTemplateSlug }; } return settings; } catch { return {}; } } private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> { const match = pathname.match(/^\/assets\/([^/]+)$/); if (!match) return null; const assetName = match[1]; const assetDefinition = PREVIEW_ASSETS[assetName]; if (!assetDefinition) return null; try { const body = assetDefinition.sourceText !== undefined ? Buffer.from(assetDefinition.sourceText, 'utf-8') : await readFile(require.resolve(assetDefinition.modulePath as string)); return { contentType: assetDefinition.contentType, body, }; } catch (error) { console.error(`[PreviewServer] Failed to read local asset: ${assetDefinition.modulePath ?? assetName}`, error); return null; } } private async resolveImageAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> { const match = pathname.match(/^\/images\/([^/]+)$/); if (!match) return null; const assetName = match[1] as keyof typeof PREVIEW_IMAGE_ASSETS; const assetDefinition = PREVIEW_IMAGE_ASSETS[assetName]; if (!assetDefinition) return null; try { const absolutePath = require.resolve(assetDefinition.modulePath); const body = await readFile(absolutePath); return { contentType: assetDefinition.contentType, body, }; } catch (error) { console.error(`[PreviewServer] Failed to read image asset: ${assetDefinition.modulePath}`, error); return null; } } private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> { const match = pathname.match(/^\/media\/(.+)$/); if (!match || !dataDir) return null; const relativeMediaPath = path.posix.normalize(`media/${match[1]}`); if (!relativeMediaPath.startsWith('media/')) { return null; } const absoluteDataDir = path.resolve(dataDir); const mediaRoot = path.resolve(absoluteDataDir, 'media'); const absoluteMediaPath = path.resolve(absoluteDataDir, relativeMediaPath); if (absoluteMediaPath !== mediaRoot && !absoluteMediaPath.startsWith(`${mediaRoot}${path.sep}`)) { return null; } try { const body = await readFile(absoluteMediaPath); return { contentType: this.getMediaContentType(absoluteMediaPath), body, }; } catch { return null; } } private async resolveCalendarJson(dataDir: string | undefined, listExcludedCategories: string[]): Promise { if (dataDir) { const calendarPath = path.join(dataDir, 'html', 'calendar.json'); try { return await readFile(calendarPath); } catch { // fall through to dynamic generation for preview runtime } } const { publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories); const calendarJson = `${JSON.stringify(buildCalendarArchiveData(publishedListPosts), null, 2)}\n`; return Buffer.from(calendarJson, 'utf-8'); } private getMediaContentType(filePath: string): string { const extension = path.extname(filePath).toLowerCase(); switch (extension) { case '.jpg': case '.jpeg': return 'image/jpeg'; case '.png': return 'image/png'; case '.gif': return 'image/gif'; case '.webp': return 'image/webp'; case '.svg': return 'image/svg+xml'; case '.bmp': return 'image/bmp'; case '.avif': return 'image/avif'; case '.mp4': return 'video/mp4'; case '.webm': return 'video/webm'; case '.mov': return 'video/quicktime'; case '.pdf': return 'application/pdf'; default: return 'application/octet-stream'; } } private resolveCategoryMetadata(metadata: ProjectMetadata | null): Record { const defaults: Record = { article: { renderInLists: true, showTitle: true, title: 'article' }, picture: { renderInLists: true, showTitle: true, title: 'picture' }, aside: { renderInLists: true, showTitle: false, title: 'aside' }, page: { renderInLists: false, showTitle: true, title: 'page' }, }; const rawMetadata = (metadata as { categoryMetadata?: unknown } | null)?.categoryMetadata; const rawLegacySettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings; const source = rawMetadata && typeof rawMetadata === 'object' ? rawMetadata : rawLegacySettings; if (!source || typeof source !== 'object') { return defaults; } const merged: Record = { ...defaults }; for (const [category, rawValue] of Object.entries(source as Record)) { if (!rawValue || typeof rawValue !== 'object') { continue; } const typedRawValue = rawValue as Record; const title = typeof typedRawValue.title === 'string' && typedRawValue.title.trim().length > 0 ? typedRawValue.title.trim() : category; merged[category] = { renderInLists: typedRawValue.renderInLists !== false, showTitle: typedRawValue.showTitle !== false, title, }; } return merged; } private resolveCategorySettings(metadata: ProjectMetadata | null): Record { const categoryMetadata = this.resolveCategoryMetadata(metadata); const mergedSettings: Record = {}; for (const [category, value] of Object.entries(categoryMetadata)) { mergedSettings[category] = { renderInLists: value.renderInLists, showTitle: value.showTitle, postTemplateSlug: value.postTemplateSlug, listTemplateSlug: value.listTemplateSlug, }; } return mergedSettings; } private resolveListExcludedCategories(categorySettings: Record): string[] { return Object.entries(categorySettings) .filter(([, settings]) => settings.renderInLists === false) .map(([category]) => category); } private respond(res: ServerResponse, status: number, body: string): void { res.statusCode = status; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); res.end(body); } private respondAsset(res: ServerResponse, contentType: string, body: Buffer): void { res.statusCode = 200; res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'no-store'); res.end(body); } } function buildNoopMacroRenderer(): PythonMacroRendererContract { return { async getEnabledMacroScripts() { return []; }, async renderMacro() { throw new Error('Python macro renderer not configured'); }, }; }