fix: refactored code to properly share between preview and render

This commit is contained in:
2026-02-22 08:21:08 +01:00
parent b437d79230
commit 20ef4588bf
4 changed files with 295 additions and 146 deletions

View File

@@ -18,6 +18,8 @@ import {
} from './PageRenderer'; } from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes'; import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
import type { MenuDocument } from './MenuEngine'; import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import { PreviewServer } from './PreviewServer';
const DEFAULT_MAX_POSTS_PER_PAGE = 50; const DEFAULT_MAX_POSTS_PER_PAGE = 50;
const MIN_MAX_POSTS_PER_PAGE = 1; const MIN_MAX_POSTS_PER_PAGE = 1;
@@ -677,44 +679,34 @@ export class BlogGenerationEngine {
reportUnitProgress('Assets copied'); reportUnitProgress('Assets copied');
} }
const pageTitle = options.pageTitle || options.projectName; const renderRoute = this.createSharedRouteRenderer(options, maxPostsPerPage);
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);
let pagesGenerated = 0; let pagesGenerated = 0;
if (includeCore) { if (includeCore) {
onProgress(20, 'Generating root pages...'); onProgress(20, 'Generating root pages...');
pagesGenerated += await this.generateRootPages(options.projectId, publishedListPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress); pagesGenerated += await this.generateRootPages(options.projectId, publishedListPosts, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress);
pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress); pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, htmlDir, renderRoute, reportUnitProgress);
} }
if (includeSingle) { if (includeSingle) {
onProgress(35, 'Generating single post pages...'); 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) { if (includeCategory) {
onProgress(50, 'Generating category pages...'); 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) { if (includeTag) {
onProgress(65, 'Generating tag pages...'); 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) { if (includeDate) {
onProgress(80, 'Generating date archive pages...'); 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)`); onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`);
@@ -1293,16 +1285,7 @@ export class BlogGenerationEngine {
const htmlDir = path.join(options.dataDir, 'html'); const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true }); await fs.mkdir(htmlDir, { recursive: true });
const pageTitle = options.pageTitle || options.projectName; const renderRoute = this.createSharedRouteRenderer(options, maxPostsPerPage);
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 onPageGenerated = (_message: string) => { const onPageGenerated = (_message: string) => {
// no-op for applyValidation // no-op for applyValidation
}; };
@@ -1358,12 +1341,9 @@ export class BlogGenerationEngine {
renderedUrlCount += await this.generateRootPages( renderedUrlCount += await this.generateRootPages(
options.projectId, options.projectId,
publishedListPosts, publishedListPosts,
rewriteContext,
maxPostsPerPage, maxPostsPerPage,
htmlDir, htmlDir,
pageContext, renderRoute,
pageRenderer,
categorySettings,
onPageGenerated, onPageGenerated,
); );
} }
@@ -1372,10 +1352,8 @@ export class BlogGenerationEngine {
renderedUrlCount += await this.generatePageRoutes( renderedUrlCount += await this.generatePageRoutes(
options.projectId, options.projectId,
requestedPagePosts, requestedPagePosts,
rewriteContext,
htmlDir, htmlDir,
pageContext, renderRoute,
pageRenderer,
onPageGenerated, onPageGenerated,
); );
} }
@@ -1385,13 +1363,9 @@ export class BlogGenerationEngine {
options.projectId, options.projectId,
publishedListPosts, publishedListPosts,
requestedCategorySet, requestedCategorySet,
rewriteContext,
maxPostsPerPage, maxPostsPerPage,
htmlDir, htmlDir,
pageContext, renderRoute,
pageRenderer,
categorySettings,
options.categoryMetadata,
onPageGenerated, onPageGenerated,
); );
} }
@@ -1401,12 +1375,9 @@ export class BlogGenerationEngine {
options.projectId, options.projectId,
publishedListPosts, publishedListPosts,
requestedTagSet, requestedTagSet,
rewriteContext,
maxPostsPerPage, maxPostsPerPage,
htmlDir, htmlDir,
pageContext, renderRoute,
pageRenderer,
categorySettings,
onPageGenerated, onPageGenerated,
); );
} }
@@ -1415,10 +1386,8 @@ export class BlogGenerationEngine {
renderedUrlCount += await this.generateSinglePostPages( renderedUrlCount += await this.generateSinglePostPages(
options.projectId, options.projectId,
requestedSinglePosts, requestedSinglePosts,
rewriteContext,
htmlDir, htmlDir,
pageContext, renderRoute,
pageRenderer,
onPageGenerated, onPageGenerated,
); );
} }
@@ -1430,12 +1399,9 @@ export class BlogGenerationEngine {
requestedYearsMap, requestedYearsMap,
requestedYearMonthsMap, requestedYearMonthsMap,
requestedYearMonthDaysMap, requestedYearMonthDaysMap,
rewriteContext,
maxPostsPerPage, maxPostsPerPage,
htmlDir, htmlDir,
pageContext, renderRoute,
pageRenderer,
categorySettings,
onPageGenerated, onPageGenerated,
); );
} }
@@ -1453,17 +1419,16 @@ export class BlogGenerationEngine {
private async generatePageRoutes( private async generatePageRoutes(
projectId: string, projectId: string,
posts: PostData[], posts: PostData[],
rewriteContext: HtmlRewriteContext,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[] }, renderRoute: (pathname: string) => Promise<string | null>,
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; let count = 0;
const pagePosts = posts.filter((post) => (post.categories || []).includes('page')); const pagePosts = posts.filter((post) => (post.categories || []).includes('page'));
for (const post of pagePosts) { 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); await writeHtmlPage(projectId, htmlDir, post.slug, html);
count++; count++;
onPageGenerated(`Generated /${post.slug}`); onPageGenerated(`Generated /${post.slug}`);
@@ -1472,18 +1437,63 @@ export class BlogGenerationEngine {
return count; return count;
} }
private buildHtmlRewriteContext(publishedPosts: PostData[]): HtmlRewriteContext { private createSharedRouteRenderer(
const canonicalPostPathBySlug = new Map<string, string>(); options: BlogGenerationOptions,
for (const post of publishedPosts) { maxPostsPerPage: number,
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post)); ): (pathname: string) => Promise<string | null> {
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<string | null> => {
return previewServer.renderRouteForContext(pathname, {
projectContext,
metadata,
menu,
maxPostsPerPage,
});
};
} }
const canonicalMediaPathBySourcePath = new Map<string, string>(); private async renderRequiredRoute(
renderRoute: (pathname: string) => Promise<string | null>,
pathname: string,
): Promise<string> {
const html = await renderRoute(pathname);
if (html !== null) {
return html;
}
return { throw new Error(`Shared route renderer returned null for required path: ${pathname}`);
canonicalPostPathBySlug,
canonicalMediaPathBySourcePath,
};
} }
private async copyAssets(htmlDir: string): Promise<void> { private async copyAssets(htmlDir: string): Promise<void> {
@@ -1511,12 +1521,9 @@ export class BlogGenerationEngine {
private async generateRootPages( private async generateRootPages(
projectId: string, projectId: string,
posts: PostData[], posts: PostData[],
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, renderRoute: (pathname: string) => Promise<string | null>,
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage)); const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage));
@@ -1527,15 +1534,8 @@ export class BlogGenerationEngine {
const pagePosts = posts.slice(offset, offset + maxPostsPerPage); const pagePosts = posts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break; if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { const routePath = page === 1 ? '/' : `/page/${page}`;
archiveGrouping: true, const html = await this.renderRequiredRoute(renderRoute, routePath);
routeKind: 'date',
archiveContext: { kind: 'root' },
basePathname: '/',
pagination: { page, maxPostsPerPage, totalPosts: posts.length },
categorySettings,
...pageContext,
});
if (html) { if (html) {
const urlPath = page === 1 ? '' : `page/${page}`; const urlPath = page === 1 ? '' : `page/${page}`;
@@ -1551,10 +1551,8 @@ export class BlogGenerationEngine {
private async generateSinglePostPages( private async generateSinglePostPages(
projectId: string, projectId: string,
posts: PostData[], posts: PostData[],
rewriteContext: HtmlRewriteContext,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, renderRoute: (pathname: string) => Promise<string | null>,
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; let count = 0;
@@ -1565,8 +1563,8 @@ export class BlogGenerationEngine {
const month = String(createdAt.getMonth() + 1).padStart(2, '0'); const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).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 urlPath = `${year}/${month}/${day}/${post.slug}`;
const html = await this.renderRequiredRoute(renderRoute, `/${urlPath}`);
await writeHtmlPage(projectId, htmlDir, urlPath, html); await writeHtmlPage(projectId, htmlDir, urlPath, html);
count++; count++;
onPageGenerated(`Generated /${urlPath}`); onPageGenerated(`Generated /${urlPath}`);
@@ -1579,13 +1577,9 @@ export class BlogGenerationEngine {
projectId: string, projectId: string,
posts: PostData[], posts: PostData[],
allCategories: Set<string>, allCategories: Set<string>,
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, renderRoute: (pathname: string) => Promise<string | null>,
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
categoryMetadata: Record<string, CategoryMetadata> | undefined,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; let count = 0;
@@ -1594,26 +1588,18 @@ export class BlogGenerationEngine {
const categoryPosts = posts.filter((post) => (post.categories || []).includes(category)); const categoryPosts = posts.filter((post) => (post.categories || []).includes(category));
if (categoryPosts.length === 0) continue; if (categoryPosts.length === 0) continue;
const categoryDisplayTitle = resolveCategoryDisplayTitle(category, categoryMetadata);
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage)); const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage));
const encodedCategory = encodeURIComponent(category); const encodedCategory = encodeURIComponent(category);
const basePathname = `/category/${encodedCategory}`;
for (let page = 1; page <= totalPages; page++) { for (let page = 1; page <= totalPages; page++) {
const offset = (page - 1) * maxPostsPerPage; const offset = (page - 1) * maxPostsPerPage;
const pagePosts = categoryPosts.slice(offset, offset + maxPostsPerPage); const pagePosts = categoryPosts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break; if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { const routePath = page === 1
archiveGrouping: true, ? `/category/${encodedCategory}`
routeKind: 'non-date', : `/category/${encodedCategory}/page/${page}`;
archiveContext: { kind: 'category', name: categoryDisplayTitle }, const html = await this.renderRequiredRoute(renderRoute, routePath);
basePathname,
pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length },
categorySettings,
...pageContext,
});
if (html) { if (html) {
const urlPath = page === 1 const urlPath = page === 1
@@ -1633,12 +1619,9 @@ export class BlogGenerationEngine {
projectId: string, projectId: string,
posts: PostData[], posts: PostData[],
allTags: Set<string>, allTags: Set<string>,
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, renderRoute: (pathname: string) => Promise<string | null>,
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; let count = 0;
@@ -1649,22 +1632,16 @@ export class BlogGenerationEngine {
const totalPages = Math.max(1, Math.ceil(tagPosts.length / maxPostsPerPage)); const totalPages = Math.max(1, Math.ceil(tagPosts.length / maxPostsPerPage));
const encodedTag = encodeURIComponent(tag); const encodedTag = encodeURIComponent(tag);
const basePathname = `/tag/${encodedTag}`;
for (let page = 1; page <= totalPages; page++) { for (let page = 1; page <= totalPages; page++) {
const offset = (page - 1) * maxPostsPerPage; const offset = (page - 1) * maxPostsPerPage;
const pagePosts = tagPosts.slice(offset, offset + maxPostsPerPage); const pagePosts = tagPosts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break; if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { const routePath = page === 1
archiveGrouping: true, ? `/tag/${encodedTag}`
routeKind: 'non-date', : `/tag/${encodedTag}/page/${page}`;
archiveContext: { kind: 'tag', name: tag }, const html = await this.renderRequiredRoute(renderRoute, routePath);
basePathname,
pagination: { page, maxPostsPerPage, totalPosts: tagPosts.length },
categorySettings,
...pageContext,
});
if (html) { if (html) {
const urlPath = page === 1 const urlPath = page === 1
@@ -1686,12 +1663,9 @@ export class BlogGenerationEngine {
yearsMap: Map<number, Date>, yearsMap: Map<number, Date>,
yearMonthsMap: Map<string, Date>, yearMonthsMap: Map<string, Date>,
yearMonthDaysMap: Map<string, Date>, yearMonthDaysMap: Map<string, Date>,
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, renderRoute: (pathname: string) => Promise<string | null>,
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; 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])) { for (const [year] of Array.from(yearsMap.entries()).sort((a, b) => b[0] - a[0])) {
const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year); const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year);
count += await this.generatePaginatedListPages( count += await this.generatePaginatedListPages(
projectId, yearPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated, projectId, yearPosts, maxPostsPerPage, htmlDir, renderRoute, onPageGenerated,
`${year}`, `/${year}`, { kind: 'year', year }, 'date', `${year}`,
); );
} }
@@ -1713,8 +1687,8 @@ export class BlogGenerationEngine {
return d.getFullYear() === year && (d.getMonth() + 1) === month; return d.getFullYear() === year && (d.getMonth() + 1) === month;
}); });
count += await this.generatePaginatedListPages( count += await this.generatePaginatedListPages(
projectId, monthPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated, projectId, monthPosts, maxPostsPerPage, htmlDir, renderRoute, onPageGenerated,
ym, `/${ym}`, { kind: 'month', year, month }, 'date', ym,
); );
} }
@@ -1728,8 +1702,8 @@ export class BlogGenerationEngine {
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
}); });
count += await this.generatePaginatedListPages( count += await this.generatePaginatedListPages(
projectId, dayPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated, projectId, dayPosts, maxPostsPerPage, htmlDir, renderRoute, onPageGenerated,
ymd, `/${ymd}`, { kind: 'day', year, month, day }, 'date', ymd,
); );
} }
@@ -1739,17 +1713,11 @@ export class BlogGenerationEngine {
private async generatePaginatedListPages( private async generatePaginatedListPages(
projectId: string, projectId: string,
posts: PostData[], posts: PostData[],
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, renderRoute: (pathname: string) => Promise<string | null>,
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
urlPrefix: string, 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<number> { ): Promise<number> {
if (posts.length === 0) return 0; if (posts.length === 0) return 0;
@@ -1761,15 +1729,8 @@ export class BlogGenerationEngine {
const pagePosts = posts.slice(offset, offset + maxPostsPerPage); const pagePosts = posts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break; if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { const routePath = page === 1 ? `/${urlPrefix}` : `/${urlPrefix}/page/${page}`;
archiveGrouping: true, const html = await this.renderRequiredRoute(renderRoute, routePath);
routeKind,
archiveContext,
basePathname,
pagination: { page, maxPostsPerPage, totalPosts: posts.length },
categorySettings,
...pageContext,
});
if (html) { if (html) {
const urlPath = page === 1 const urlPath = page === 1

View File

@@ -22,6 +22,7 @@ import {
type PostMediaEngineContract, type PostMediaEngineContract,
} from './PageRenderer'; } from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
interface ActiveProjectContext { interface ActiveProjectContext {
projectId: string; projectId: string;
@@ -153,6 +154,50 @@ export class PreviewServer {
return `http://127.0.0.1:${this.port}`; 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<string | null> {
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<void> { private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const remoteAddress = req.socket.remoteAddress; const remoteAddress = req.socket.remoteAddress;
const isLocal = remoteAddress === '127.0.0.1' const isLocal = remoteAddress === '127.0.0.1'
@@ -230,15 +275,17 @@ export class PreviewServer {
return; return;
} }
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, { const result = await this.renderRouteForContext(pathname, {
pageTitle, projectContext: context,
language, metadata,
menuItems, menu,
picoStylesheetHref, maxPostsPerPage,
requestTheme,
htmlThemeAttribute: undefined, htmlThemeAttribute: undefined,
}, categorySettings, categoryMetadata, listExcludedCategories, { singlePostOptions: {
useDraftContent, useDraftContent,
draftPostId, draftPostId,
},
}); });
if (!result) { if (!result) {
const notFoundHtml = await this.pageRenderer.renderNotFound({ const notFoundHtml = await this.pageRenderer.renderNotFound({

View File

@@ -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<CategoryMetadata> {
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<ProjectMetadata | null>;
isInitialized?: () => boolean;
syncOnStartup?: () => Promise<void>;
};
menuEngine: {
setProjectContext: (projectId: string, dataDir?: string) => void;
getMenu: () => Promise<MenuDocument>;
};
resolveCategoryMetadata: (metadata: ProjectMetadata | null) => Record<string, CategoryMetadata>;
resolveCategorySettings: (metadata: ProjectMetadata | null) => Record<string, CategoryRenderSettings>;
resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[];
buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>;
resolveRoute: (
pathname: string,
maxPostsPerPage: number,
rewriteContext: HtmlRewriteContext,
pageContext: {
pageTitle: string;
language: string;
menuItems: ReturnType<typeof buildTemplateMenuItems>;
picoStylesheetHref: string;
htmlThemeAttribute?: string;
},
categorySettings: Record<string, CategoryRenderSettings>,
categoryMetadata: Record<string, CategoryMetadata>,
listExcludedCategories: string[],
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
) => Promise<string | null>;
}
export async function renderRouteWithSharedContext<CategoryMetadata>(
pathname: string,
options: SharedRouteRenderOptions,
services: SharedRouteRenderServices<CategoryMetadata>,
): Promise<string | null> {
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<string, { title?: string }>);
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);
}

View File

@@ -138,6 +138,7 @@ async function listFiles(dir: string, prefix = ''): Promise<string[]> {
describe('BlogGenerationEngine', () => { describe('BlogGenerationEngine', () => {
let tempDir: string; let tempDir: string;
let mockPostEngine: any; let mockPostEngine: any;
let mockMediaEngine: any;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -146,6 +147,8 @@ describe('BlogGenerationEngine', () => {
const { __mockPostEngine } = await import('../../src/main/engine/PostEngine') as any; const { __mockPostEngine } = await import('../../src/main/engine/PostEngine') as any;
mockPostEngine = __mockPostEngine; mockPostEngine = __mockPostEngine;
const { __mockMediaEngine } = await import('../../src/main/engine/MediaEngine') as any;
mockMediaEngine = __mockMediaEngine;
}); });
afterEach(async () => { afterEach(async () => {
@@ -1126,6 +1129,33 @@ describe('BlogGenerationEngine', () => {
expect(await fileExists(path.join(tempDir, 'html', 'post', '2025', '03', 'alias-test', 'index.html'))).toBe(false); 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 () => { it('does not overwrite unchanged html files on subsequent generation runs', async () => {
const posts = [ const posts = [
makePost({ id: '1', slug: 'stable-post', createdAt: new Date('2025-03-15T10:00:00Z') }), makePost({ id: '1', slug: 'stable-post', createdAt: new Date('2025-03-15T10:00:00Z') }),