fix: refactored code to properly share between preview and render
This commit is contained in:
@@ -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<string | null>,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
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<string, string>();
|
||||
for (const post of publishedPosts) {
|
||||
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
|
||||
private createSharedRouteRenderer(
|
||||
options: BlogGenerationOptions,
|
||||
maxPostsPerPage: number,
|
||||
): (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 {
|
||||
canonicalPostPathBySlug,
|
||||
canonicalMediaPathBySourcePath,
|
||||
};
|
||||
throw new Error(`Shared route renderer returned null for required path: ${pathname}`);
|
||||
}
|
||||
|
||||
private async copyAssets(htmlDir: string): Promise<void> {
|
||||
@@ -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<string, CategoryRenderSettings>,
|
||||
renderRoute: (pathname: string) => Promise<string | null>,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
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<string | null>,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
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<string>,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
|
||||
pageRenderer: PageRenderer,
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||
renderRoute: (pathname: string) => Promise<string | null>,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
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<string>,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
|
||||
pageRenderer: PageRenderer,
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
renderRoute: (pathname: string) => Promise<string | null>,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
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<number, Date>,
|
||||
yearMonthsMap: Map<string, Date>,
|
||||
yearMonthDaysMap: Map<string, Date>,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
|
||||
pageRenderer: PageRenderer,
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
renderRoute: (pathname: string) => Promise<string | null>,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
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<string, CategoryRenderSettings>,
|
||||
renderRoute: (pathname: string) => Promise<string | null>,
|
||||
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<number> {
|
||||
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
|
||||
|
||||
@@ -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<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> {
|
||||
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, {
|
||||
singlePostOptions: {
|
||||
useDraftContent,
|
||||
draftPostId,
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
const notFoundHtml = await this.pageRenderer.renderNotFound({
|
||||
|
||||
111
src/main/engine/SharedRouteRenderer.ts
Normal file
111
src/main/engine/SharedRouteRenderer.ts
Normal 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);
|
||||
}
|
||||
@@ -138,6 +138,7 @@ async function listFiles(dir: string, prefix = ''): Promise<string[]> {
|
||||
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: '',
|
||||
}),
|
||||
];
|
||||
|
||||
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') }),
|
||||
|
||||
Reference in New Issue
Block a user