fix: refactored code to properly share between preview and render
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderRequiredRoute(
|
||||||
|
renderRoute: (pathname: string) => Promise<string | null>,
|
||||||
|
pathname: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const html = await renderRoute(pathname);
|
||||||
|
if (html !== null) {
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalMediaPathBySourcePath = new Map<string, string>();
|
throw new Error(`Shared route renderer returned null for required path: ${pathname}`);
|
||||||
|
|
||||||
return {
|
|
||||||
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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
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', () => {
|
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: '',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
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') }),
|
||||||
|
|||||||
Reference in New Issue
Block a user