diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 2e8b334..2ff46e2 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -18,6 +18,8 @@ import { } from './PageRenderer'; import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes'; import type { MenuDocument } from './MenuEngine'; +import type { ProjectMetadata } from './MetaEngine'; +import { PreviewServer } from './PreviewServer'; const DEFAULT_MAX_POSTS_PER_PAGE = 50; const MIN_MAX_POSTS_PER_PAGE = 1; @@ -677,44 +679,34 @@ export class BlogGenerationEngine { reportUnitProgress('Assets copied'); } - const pageTitle = options.pageTitle || options.projectName; - const language = options.language || 'en'; - const pageContext = { - page_title: pageTitle, - language, - menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata), - pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)), - }; - - const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine); - const rewriteContext = this.buildHtmlRewriteContext(publishedPosts); + const renderRoute = this.createSharedRouteRenderer(options, maxPostsPerPage); let pagesGenerated = 0; if (includeCore) { onProgress(20, 'Generating root pages...'); - pagesGenerated += await this.generateRootPages(options.projectId, publishedListPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress); - pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress); + pagesGenerated += await this.generateRootPages(options.projectId, publishedListPosts, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress); + pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, htmlDir, renderRoute, reportUnitProgress); } if (includeSingle) { onProgress(35, 'Generating single post pages...'); - pagesGenerated += await this.generateSinglePostPages(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress); + pagesGenerated += await this.generateSinglePostPages(options.projectId, publishedPosts, htmlDir, renderRoute, reportUnitProgress); } if (includeCategory) { onProgress(50, 'Generating category pages...'); - pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, options.categoryMetadata, reportUnitProgress); + pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress); } if (includeTag) { onProgress(65, 'Generating tag pages...'); - pagesGenerated += await this.generateTagPages(options.projectId, publishedListPosts, allTags, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress); + pagesGenerated += await this.generateTagPages(options.projectId, publishedListPosts, allTags, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress); } if (includeDate) { onProgress(80, 'Generating date archive pages...'); - pagesGenerated += await this.generateDateArchivePages(options.projectId, publishedListPosts, years, yearMonths, yearMonthDays, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress); + pagesGenerated += await this.generateDateArchivePages(options.projectId, publishedListPosts, years, yearMonths, yearMonthDays, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress); } onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`); @@ -1293,16 +1285,7 @@ export class BlogGenerationEngine { const htmlDir = path.join(options.dataDir, 'html'); await fs.mkdir(htmlDir, { recursive: true }); - const pageTitle = options.pageTitle || options.projectName; - const language = options.language || 'en'; - const pageContext = { - page_title: pageTitle, - language, - menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata), - pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)), - }; - const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine); - const rewriteContext = this.buildHtmlRewriteContext(publishedPosts); + const renderRoute = this.createSharedRouteRenderer(options, maxPostsPerPage); const onPageGenerated = (_message: string) => { // no-op for applyValidation }; @@ -1358,12 +1341,9 @@ export class BlogGenerationEngine { renderedUrlCount += await this.generateRootPages( options.projectId, publishedListPosts, - rewriteContext, maxPostsPerPage, htmlDir, - pageContext, - pageRenderer, - categorySettings, + renderRoute, onPageGenerated, ); } @@ -1372,10 +1352,8 @@ export class BlogGenerationEngine { renderedUrlCount += await this.generatePageRoutes( options.projectId, requestedPagePosts, - rewriteContext, htmlDir, - pageContext, - pageRenderer, + renderRoute, onPageGenerated, ); } @@ -1385,13 +1363,9 @@ export class BlogGenerationEngine { options.projectId, publishedListPosts, requestedCategorySet, - rewriteContext, maxPostsPerPage, htmlDir, - pageContext, - pageRenderer, - categorySettings, - options.categoryMetadata, + renderRoute, onPageGenerated, ); } @@ -1401,12 +1375,9 @@ export class BlogGenerationEngine { options.projectId, publishedListPosts, requestedTagSet, - rewriteContext, maxPostsPerPage, htmlDir, - pageContext, - pageRenderer, - categorySettings, + renderRoute, onPageGenerated, ); } @@ -1415,10 +1386,8 @@ export class BlogGenerationEngine { renderedUrlCount += await this.generateSinglePostPages( options.projectId, requestedSinglePosts, - rewriteContext, htmlDir, - pageContext, - pageRenderer, + renderRoute, onPageGenerated, ); } @@ -1430,12 +1399,9 @@ export class BlogGenerationEngine { requestedYearsMap, requestedYearMonthsMap, requestedYearMonthDaysMap, - rewriteContext, maxPostsPerPage, htmlDir, - pageContext, - pageRenderer, - categorySettings, + renderRoute, onPageGenerated, ); } @@ -1453,17 +1419,16 @@ export class BlogGenerationEngine { private async generatePageRoutes( projectId: string, posts: PostData[], - rewriteContext: HtmlRewriteContext, htmlDir: string, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[] }, - pageRenderer: PageRenderer, + renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, ): Promise { let count = 0; const pagePosts = posts.filter((post) => (post.categories || []).includes('page')); for (const post of pagePosts) { - const html = await pageRenderer.renderSinglePost(post, rewriteContext, pageContext); + const routePath = `/${post.slug}`; + const html = await this.renderRequiredRoute(renderRoute, routePath); await writeHtmlPage(projectId, htmlDir, post.slug, html); count++; onPageGenerated(`Generated /${post.slug}`); @@ -1472,18 +1437,63 @@ export class BlogGenerationEngine { return count; } - private buildHtmlRewriteContext(publishedPosts: PostData[]): HtmlRewriteContext { - const canonicalPostPathBySlug = new Map(); - for (const post of publishedPosts) { - canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post)); + private createSharedRouteRenderer( + options: BlogGenerationOptions, + maxPostsPerPage: number, + ): (pathname: string) => Promise { + const metadata: ProjectMetadata = { + name: options.projectName, + description: options.projectDescription, + mainLanguage: options.language, + maxPostsPerPage, + picoTheme: options.picoTheme, + categoryMetadata: options.categoryMetadata, + categorySettings: options.categorySettings, + }; + + const menu = options.menu ?? { items: [] }; + const projectContext = { + projectId: options.projectId, + dataDir: options.dataDir, + projectName: options.projectName, + projectDescription: options.projectDescription, + }; + + const previewServer = new PreviewServer({ + postEngine: this.postEngine, + mediaEngine: this.mediaEngine, + postMediaEngine: this.postMediaEngine, + settingsEngine: { + setProjectContext: () => {}, + getProjectMetadata: async () => metadata, + }, + menuEngine: { + setProjectContext: () => {}, + getMenu: async () => menu, + }, + getActiveProjectContext: async () => projectContext, + }); + + return async (pathname: string): Promise => { + return previewServer.renderRouteForContext(pathname, { + projectContext, + metadata, + menu, + maxPostsPerPage, + }); + }; + } + + private async renderRequiredRoute( + renderRoute: (pathname: string) => Promise, + pathname: string, + ): Promise { + const html = await renderRoute(pathname); + if (html !== null) { + return html; } - const canonicalMediaPathBySourcePath = new Map(); - - return { - canonicalPostPathBySlug, - canonicalMediaPathBySourcePath, - }; + throw new Error(`Shared route renderer returned null for required path: ${pathname}`); } private async copyAssets(htmlDir: string): Promise { @@ -1511,12 +1521,9 @@ export class BlogGenerationEngine { private async generateRootPages( projectId: string, posts: PostData[], - rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, - pageRenderer: PageRenderer, - categorySettings: Record, + renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, ): Promise { const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage)); @@ -1527,15 +1534,8 @@ export class BlogGenerationEngine { const pagePosts = posts.slice(offset, offset + maxPostsPerPage); if (pagePosts.length === 0) break; - const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { - archiveGrouping: true, - routeKind: 'date', - archiveContext: { kind: 'root' }, - basePathname: '/', - pagination: { page, maxPostsPerPage, totalPosts: posts.length }, - categorySettings, - ...pageContext, - }); + const routePath = page === 1 ? '/' : `/page/${page}`; + const html = await this.renderRequiredRoute(renderRoute, routePath); if (html) { const urlPath = page === 1 ? '' : `page/${page}`; @@ -1551,10 +1551,8 @@ export class BlogGenerationEngine { private async generateSinglePostPages( projectId: string, posts: PostData[], - rewriteContext: HtmlRewriteContext, htmlDir: string, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, - pageRenderer: PageRenderer, + renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, ): Promise { let count = 0; @@ -1565,8 +1563,8 @@ export class BlogGenerationEngine { const month = String(createdAt.getMonth() + 1).padStart(2, '0'); const day = String(createdAt.getDate()).padStart(2, '0'); - const html = await pageRenderer.renderSinglePost(post, rewriteContext, pageContext); const urlPath = `${year}/${month}/${day}/${post.slug}`; + const html = await this.renderRequiredRoute(renderRoute, `/${urlPath}`); await writeHtmlPage(projectId, htmlDir, urlPath, html); count++; onPageGenerated(`Generated /${urlPath}`); @@ -1579,13 +1577,9 @@ export class BlogGenerationEngine { projectId: string, posts: PostData[], allCategories: Set, - rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, - pageRenderer: PageRenderer, - categorySettings: Record, - categoryMetadata: Record | undefined, + renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, ): Promise { let count = 0; @@ -1594,26 +1588,18 @@ export class BlogGenerationEngine { const categoryPosts = posts.filter((post) => (post.categories || []).includes(category)); if (categoryPosts.length === 0) continue; - const categoryDisplayTitle = resolveCategoryDisplayTitle(category, categoryMetadata); - const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage)); const encodedCategory = encodeURIComponent(category); - const basePathname = `/category/${encodedCategory}`; for (let page = 1; page <= totalPages; page++) { const offset = (page - 1) * maxPostsPerPage; const pagePosts = categoryPosts.slice(offset, offset + maxPostsPerPage); if (pagePosts.length === 0) break; - const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { - archiveGrouping: true, - routeKind: 'non-date', - archiveContext: { kind: 'category', name: categoryDisplayTitle }, - basePathname, - pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length }, - categorySettings, - ...pageContext, - }); + const routePath = page === 1 + ? `/category/${encodedCategory}` + : `/category/${encodedCategory}/page/${page}`; + const html = await this.renderRequiredRoute(renderRoute, routePath); if (html) { const urlPath = page === 1 @@ -1633,12 +1619,9 @@ export class BlogGenerationEngine { projectId: string, posts: PostData[], allTags: Set, - rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, - pageRenderer: PageRenderer, - categorySettings: Record, + renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, ): Promise { let count = 0; @@ -1649,22 +1632,16 @@ export class BlogGenerationEngine { const totalPages = Math.max(1, Math.ceil(tagPosts.length / maxPostsPerPage)); const encodedTag = encodeURIComponent(tag); - const basePathname = `/tag/${encodedTag}`; for (let page = 1; page <= totalPages; page++) { const offset = (page - 1) * maxPostsPerPage; const pagePosts = tagPosts.slice(offset, offset + maxPostsPerPage); if (pagePosts.length === 0) break; - const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { - archiveGrouping: true, - routeKind: 'non-date', - archiveContext: { kind: 'tag', name: tag }, - basePathname, - pagination: { page, maxPostsPerPage, totalPosts: tagPosts.length }, - categorySettings, - ...pageContext, - }); + const routePath = page === 1 + ? `/tag/${encodedTag}` + : `/tag/${encodedTag}/page/${page}`; + const html = await this.renderRequiredRoute(renderRoute, routePath); if (html) { const urlPath = page === 1 @@ -1686,12 +1663,9 @@ export class BlogGenerationEngine { yearsMap: Map, yearMonthsMap: Map, yearMonthDaysMap: Map, - rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, - pageRenderer: PageRenderer, - categorySettings: Record, + renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, ): Promise { let count = 0; @@ -1699,8 +1673,8 @@ export class BlogGenerationEngine { for (const [year] of Array.from(yearsMap.entries()).sort((a, b) => b[0] - a[0])) { const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year); count += await this.generatePaginatedListPages( - projectId, yearPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated, - `${year}`, `/${year}`, { kind: 'year', year }, 'date', + projectId, yearPosts, maxPostsPerPage, htmlDir, renderRoute, onPageGenerated, + `${year}`, ); } @@ -1713,8 +1687,8 @@ export class BlogGenerationEngine { return d.getFullYear() === year && (d.getMonth() + 1) === month; }); count += await this.generatePaginatedListPages( - projectId, monthPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated, - ym, `/${ym}`, { kind: 'month', year, month }, 'date', + projectId, monthPosts, maxPostsPerPage, htmlDir, renderRoute, onPageGenerated, + ym, ); } @@ -1728,8 +1702,8 @@ export class BlogGenerationEngine { return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; }); count += await this.generatePaginatedListPages( - projectId, dayPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated, - ymd, `/${ymd}`, { kind: 'day', year, month, day }, 'date', + projectId, dayPosts, maxPostsPerPage, htmlDir, renderRoute, onPageGenerated, + ymd, ); } @@ -1739,17 +1713,11 @@ export class BlogGenerationEngine { private async generatePaginatedListPages( projectId: string, posts: PostData[], - rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, - pageRenderer: PageRenderer, - categorySettings: Record, + renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, urlPrefix: string, - basePathname: string, - archiveContext: { kind: 'root' | 'year' | 'month' | 'day' | 'tag' | 'category'; name?: string; year?: number; month?: number; day?: number }, - routeKind: 'date' | 'non-date', ): Promise { if (posts.length === 0) return 0; @@ -1761,15 +1729,8 @@ export class BlogGenerationEngine { const pagePosts = posts.slice(offset, offset + maxPostsPerPage); if (pagePosts.length === 0) break; - const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { - archiveGrouping: true, - routeKind, - archiveContext, - basePathname, - pagination: { page, maxPostsPerPage, totalPosts: posts.length }, - categorySettings, - ...pageContext, - }); + const routePath = page === 1 ? `/${urlPrefix}` : `/${urlPrefix}/page/${page}`; + const html = await this.renderRequiredRoute(renderRoute, routePath); if (html) { const urlPath = page === 1 diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 84b7c27..39ad72a 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -22,6 +22,7 @@ import { type PostMediaEngineContract, } from './PageRenderer'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; +import { renderRouteWithSharedContext } from './SharedRouteRenderer'; interface ActiveProjectContext { projectId: string; @@ -153,6 +154,50 @@ export class PreviewServer { return `http://127.0.0.1:${this.port}`; } + async renderRouteForContext( + pathname: string, + options: { + projectContext: ActiveProjectContext; + metadata?: ProjectMetadata | null; + menu?: MenuDocument; + maxPostsPerPage?: number; + requestTheme?: string | null; + htmlThemeAttribute?: string; + 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(), + resolveRoute: ( + normalizedPathname, + maxPostsPerPage, + rewriteContext, + pageContext, + categorySettings, + categoryMetadata, + listExcludedCategories, + singlePostOptions, + ) => this.resolveRoute( + normalizedPathname, + maxPostsPerPage, + rewriteContext, + pageContext, + categorySettings, + categoryMetadata, + listExcludedCategories, + singlePostOptions, + ), + }); + } + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { const remoteAddress = req.socket.remoteAddress; const isLocal = remoteAddress === '127.0.0.1' @@ -230,15 +275,17 @@ export class PreviewServer { return; } - const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, { - pageTitle, - language, - menuItems, - picoStylesheetHref, + const result = await this.renderRouteForContext(pathname, { + projectContext: context, + metadata, + menu, + maxPostsPerPage, + requestTheme, htmlThemeAttribute: undefined, - }, categorySettings, categoryMetadata, listExcludedCategories, { - useDraftContent, - draftPostId, + singlePostOptions: { + useDraftContent, + draftPostId, + }, }); if (!result) { const notFoundHtml = await this.pageRenderer.renderNotFound({ diff --git a/src/main/engine/SharedRouteRenderer.ts b/src/main/engine/SharedRouteRenderer.ts new file mode 100644 index 0000000..c259e34 --- /dev/null +++ b/src/main/engine/SharedRouteRenderer.ts @@ -0,0 +1,111 @@ +import type { MenuDocument } from './MenuEngine'; +import type { ProjectMetadata } from './MetaEngine'; +import { getPicoStylesheetHref, sanitizePicoTheme } from '../shared/picoThemes'; +import { + buildTemplateMenuItems, + clampMaxPostsPerPage, + resolvePageTitle, + type CategoryRenderSettings, + type HtmlRewriteContext, +} from './PageRenderer'; + +export interface SharedActiveProjectContext { + projectId: string; + dataDir?: string; + projectName?: string; + projectDescription?: string; +} + +export interface SharedRouteRenderOptions { + projectContext: SharedActiveProjectContext; + metadata?: ProjectMetadata | null; + menu?: MenuDocument; + maxPostsPerPage?: number; + requestTheme?: string | null; + htmlThemeAttribute?: string; + singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }; +} + +export interface SharedRouteRenderServices { + postEngine: { + setProjectContext: (projectId: string, dataDir?: string) => void; + }; + mediaEngine: { + setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void; + }; + postMediaEngine: { + setProjectContext: (projectId: string) => void; + }; + settingsEngine: { + setProjectContext: (projectId: string, dataDir?: string) => void; + getProjectMetadata: () => Promise; + isInitialized?: () => boolean; + syncOnStartup?: () => Promise; + }; + menuEngine: { + setProjectContext: (projectId: string, dataDir?: string) => void; + getMenu: () => Promise; + }; + resolveCategoryMetadata: (metadata: ProjectMetadata | null) => Record; + resolveCategorySettings: (metadata: ProjectMetadata | null) => Record; + resolveListExcludedCategories: (settings: Record) => string[]; + buildHtmlRewriteContext: () => Promise; + resolveRoute: ( + pathname: string, + maxPostsPerPage: number, + rewriteContext: HtmlRewriteContext, + pageContext: { + pageTitle: string; + language: string; + menuItems: ReturnType; + picoStylesheetHref: string; + htmlThemeAttribute?: string; + }, + categorySettings: Record, + categoryMetadata: Record, + listExcludedCategories: string[], + singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, + ) => Promise; +} + +export async function renderRouteWithSharedContext( + pathname: string, + options: SharedRouteRenderOptions, + services: SharedRouteRenderServices, +): Promise { + services.postEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir); + services.mediaEngine.setProjectContext?.(options.projectContext.projectId, options.projectContext.dataDir, options.projectContext.dataDir); + services.postMediaEngine.setProjectContext(options.projectContext.projectId); + services.settingsEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir); + services.menuEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir); + + let metadata = options.metadata; + if (metadata === undefined) { + if (services.settingsEngine.isInitialized && services.settingsEngine.syncOnStartup && !services.settingsEngine.isInitialized()) { + await services.settingsEngine.syncOnStartup(); + } + metadata = await services.settingsEngine.getProjectMetadata(); + } + + const categoryMetadata = services.resolveCategoryMetadata(metadata ?? null); + const menu = options.menu ?? await services.menuEngine.getMenu().catch(() => ({ items: [] })); + const menuItems = buildTemplateMenuItems(menu, categoryMetadata as Record); + const categorySettings = services.resolveCategorySettings(metadata ?? null); + const listExcludedCategories = services.resolveListExcludedCategories(categorySettings); + const language = metadata?.mainLanguage?.trim() || 'en'; + const pageTitle = resolvePageTitle(metadata ?? null, options.projectContext.projectName, options.projectContext.projectDescription); + const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage ?? metadata?.maxPostsPerPage); + const appliedTheme = sanitizePicoTheme(options.requestTheme) + ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme); + const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); + const htmlRewriteContext = await services.buildHtmlRewriteContext(); + const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/'); + + return services.resolveRoute(normalizedPathname, maxPostsPerPage, htmlRewriteContext, { + pageTitle, + language, + menuItems, + picoStylesheetHref, + htmlThemeAttribute: options.htmlThemeAttribute, + }, categorySettings, categoryMetadata, listExcludedCategories, options.singlePostOptions); +} diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 4065641..c93cc90 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -138,6 +138,7 @@ async function listFiles(dir: string, prefix = ''): Promise { describe('BlogGenerationEngine', () => { let tempDir: string; let mockPostEngine: any; + let mockMediaEngine: any; beforeEach(async () => { vi.clearAllMocks(); @@ -146,6 +147,8 @@ describe('BlogGenerationEngine', () => { const { __mockPostEngine } = await import('../../src/main/engine/PostEngine') as any; mockPostEngine = __mockPostEngine; + const { __mockMediaEngine } = await import('../../src/main/engine/MediaEngine') as any; + mockMediaEngine = __mockMediaEngine; }); afterEach(async () => { @@ -1126,6 +1129,33 @@ describe('BlogGenerationEngine', () => { expect(await fileExists(path.join(tempDir, 'html', 'post', '2025', '03', 'alias-test', 'index.html'))).toBe(false); }); + it('rewrites legacy internal media image URLs to canonical media URLs in generated html', async () => { + mockMediaEngine.getAllMedia.mockResolvedValue([ + { + id: 'media-1', + filename: '3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg', + originalName: '20221111_0177.jpg', + createdAt: new Date('2022-11-11T10:00:00.000Z'), + }, + ]); + + const posts = [ + makePost({ + id: 'post-1', + slug: 'autumn-leaves', + title: 'Autumn Leaves', + createdAt: new Date('2022-11-11T10:00:00.000Z'), + content: '![autumn](/media/2022/11/20221111_0177.jpg)', + }), + ]; + + await generate(posts); + + const html = await readFile(path.join(tempDir, 'html', '2022', '11', '11', 'autumn-leaves', 'index.html'), 'utf-8'); + expect(html).toContain('/media/2022/11/3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg'); + expect(html).not.toContain('/media/2022/11/20221111_0177.jpg'); + }); + it('does not overwrite unchanged html files on subsequent generation runs', async () => { const posts = [ makePost({ id: '1', slug: 'stable-post', createdAt: new Date('2025-03-15T10:00:00Z') }),