diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 9082e09..92a42a4 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -82,6 +82,14 @@ export interface SiteValidationApplyResult { removedEmptyDirCount: number; } +interface GenerationPostIndex { + postsByCategory: Map; + postsByTag: Map; + postsByYear: Map; + postsByYearMonth: Map; + postsByYearMonthDay: Map; +} + export function resolvePublicBaseUrl(publicUrl?: string): string | null { const trimmed = (publicUrl || '').trim(); if (!trimmed) { @@ -474,6 +482,7 @@ export class BlogGenerationEngine { } const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now; + const generationPostIndex = this.buildGenerationPostIndex(publishedListPosts); onProgress(5, 'Building sitemap XML...'); @@ -490,46 +499,33 @@ export class BlogGenerationEngine { for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) { urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5')); - const yearCount = publishedListPosts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year).length; + const yearCount = generationPostIndex.postsByYear.get(year)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); } for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) { urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5')); - const [yearStr, monthStr] = ym.split('/'); - const year = Number(yearStr); - const month = Number(monthStr); - const monthCount = publishedListPosts.filter((post) => { - const d = resolvePostCreatedAt(post); - return d.getFullYear() === year && (d.getMonth() + 1) === month; - }).length; + const monthCount = generationPostIndex.postsByYearMonth.get(ym)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); } for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) { urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4')); - const [yearStr, monthStr, dayStr] = ymd.split('/'); - const year = Number(yearStr); - const month = Number(monthStr); - const day = Number(dayStr); - const dayCount = publishedListPosts.filter((post) => { - const d = resolvePostCreatedAt(post); - return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; - }).length; + const dayCount = generationPostIndex.postsByYearMonthDay.get(ymd)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3'); } for (const category of Array.from(allCategories).sort()) { urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6')); - const categoryCount = publishedListPosts.filter((post) => (post.categories || []).includes(category)).length; + const categoryCount = generationPostIndex.postsByCategory.get(category)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); } for (const tag of Array.from(allTags).sort()) { urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6')); - const tagCount = publishedListPosts.filter((post) => (post.tags || []).includes(tag)).length; + const tagCount = generationPostIndex.postsByTag.get(tag)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); } @@ -642,6 +638,7 @@ export class BlogGenerationEngine { yearMonths, yearMonthDays, maxPostsPerPage, + generationPostIndex, ); const totalEstimatedUnits = [ includeCore ? estimatedUnitsBySection.core : 0, @@ -696,17 +693,30 @@ export class BlogGenerationEngine { if (includeCategory) { onProgress(50, 'Generating category pages...'); - pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress); + pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress, generationPostIndex.postsByCategory); } if (includeTag) { onProgress(65, 'Generating tag pages...'); - pagesGenerated += await this.generateTagPages(options.projectId, publishedListPosts, allTags, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress); + pagesGenerated += await this.generateTagPages(options.projectId, publishedListPosts, allTags, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress, generationPostIndex.postsByTag); } if (includeDate) { onProgress(80, 'Generating date archive pages...'); - pagesGenerated += await this.generateDateArchivePages(options.projectId, publishedListPosts, years, yearMonths, yearMonthDays, maxPostsPerPage, htmlDir, renderRoute, reportUnitProgress); + pagesGenerated += await this.generateDateArchivePages( + options.projectId, + publishedListPosts, + years, + yearMonths, + yearMonthDays, + maxPostsPerPage, + htmlDir, + renderRoute, + reportUnitProgress, + generationPostIndex.postsByYear, + generationPostIndex.postsByYearMonth, + generationPostIndex.postsByYearMonthDay, + ); } onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`); @@ -798,6 +808,7 @@ export class BlogGenerationEngine { } const publishedListPosts = Array.from(publishedListPostById.values()) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const generationPostIndex = this.buildGenerationPostIndex(publishedListPosts); const now = new Date().toISOString(); const allTags = new Set(); @@ -866,46 +877,33 @@ export class BlogGenerationEngine { for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) { urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5')); - const yearCount = publishedListPosts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year).length; + const yearCount = generationPostIndex.postsByYear.get(year)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); } for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) { urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5')); - const [yearStr, monthStr] = ym.split('/'); - const year = Number(yearStr); - const month = Number(monthStr); - const monthCount = publishedListPosts.filter((post) => { - const d = resolvePostCreatedAt(post); - return d.getFullYear() === year && (d.getMonth() + 1) === month; - }).length; + const monthCount = generationPostIndex.postsByYearMonth.get(ym)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); } for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) { urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4')); - const [yearStr, monthStr, dayStr] = ymd.split('/'); - const year = Number(yearStr); - const month = Number(monthStr); - const day = Number(dayStr); - const dayCount = publishedListPosts.filter((post) => { - const d = resolvePostCreatedAt(post); - return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; - }).length; + const dayCount = generationPostIndex.postsByYearMonthDay.get(ymd)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3'); } for (const category of Array.from(allCategories).sort()) { urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6')); - const categoryCount = publishedListPosts.filter((post) => (post.categories || []).includes(category)).length; + const categoryCount = generationPostIndex.postsByCategory.get(category)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); } for (const tag of Array.from(allTags).sort()) { urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6')); - const tagCount = publishedListPosts.filter((post) => (post.tags || []).includes(tag)).length; + const tagCount = generationPostIndex.postsByTag.get(tag)?.length ?? 0; appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); } @@ -1192,6 +1190,7 @@ export class BlogGenerationEngine { } const publishedListPosts = Array.from(publishedListPostById.values()) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const generationPostIndex = this.buildGenerationPostIndex(publishedListPosts); const allCategories = new Set(); const allTags = new Set(); @@ -1367,6 +1366,7 @@ export class BlogGenerationEngine { htmlDir, renderRoute, onPageGenerated, + generationPostIndex.postsByCategory, ); } @@ -1379,6 +1379,7 @@ export class BlogGenerationEngine { htmlDir, renderRoute, onPageGenerated, + generationPostIndex.postsByTag, ); } @@ -1403,6 +1404,9 @@ export class BlogGenerationEngine { htmlDir, renderRoute, onPageGenerated, + generationPostIndex.postsByYear, + generationPostIndex.postsByYearMonth, + generationPostIndex.postsByYearMonthDay, ); } } @@ -1690,11 +1694,12 @@ export class BlogGenerationEngine { htmlDir: string, renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, + postsByCategory?: Map, ): Promise { let count = 0; for (const category of Array.from(allCategories).sort()) { - const categoryPosts = posts.filter((post) => (post.categories || []).includes(category)); + const categoryPosts = postsByCategory?.get(category) ?? posts.filter((post) => (post.categories || []).includes(category)); if (categoryPosts.length === 0) continue; const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage)); @@ -1732,11 +1737,12 @@ export class BlogGenerationEngine { htmlDir: string, renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, + postsByTag?: Map, ): Promise { let count = 0; for (const tag of Array.from(allTags).sort()) { - const tagPosts = posts.filter((post) => (post.tags || []).includes(tag)); + const tagPosts = postsByTag?.get(tag) ?? posts.filter((post) => (post.tags || []).includes(tag)); if (tagPosts.length === 0) continue; const totalPages = Math.max(1, Math.ceil(tagPosts.length / maxPostsPerPage)); @@ -1776,11 +1782,14 @@ export class BlogGenerationEngine { htmlDir: string, renderRoute: (pathname: string) => Promise, onPageGenerated: (message: string) => void, + postsByYear?: Map, + postsByYearMonth?: Map, + postsByYearMonthDay?: Map, ): Promise { let count = 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 = postsByYear?.get(year) ?? posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year); count += await this.generatePaginatedListPages( projectId, yearPosts, maxPostsPerPage, htmlDir, renderRoute, onPageGenerated, `${year}`, @@ -1791,7 +1800,7 @@ export class BlogGenerationEngine { const [yearStr, monthStr] = ym.split('/'); const year = Number(yearStr); const month = Number(monthStr); - const monthPosts = posts.filter((post) => { + const monthPosts = postsByYearMonth?.get(ym) ?? posts.filter((post) => { const d = resolvePostCreatedAt(post); return d.getFullYear() === year && (d.getMonth() + 1) === month; }); @@ -1806,7 +1815,7 @@ export class BlogGenerationEngine { const year = Number(yearStr); const month = Number(monthStr); const day = Number(dayStr); - const dayPosts = posts.filter((post) => { + const dayPosts = postsByYearMonthDay?.get(ymd) ?? posts.filter((post) => { const d = resolvePostCreatedAt(post); return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; }); @@ -1862,48 +1871,37 @@ export class BlogGenerationEngine { yearMonthsMap: Map, yearMonthDaysMap: Map, maxPostsPerPage: number, + postIndex?: GenerationPostIndex, ): Record { + const index = postIndex ?? this.buildGenerationPostIndex(posts); const rootPages = this.countPaginatedPages(posts.length, maxPostsPerPage); - const pageRoutes = posts.filter((post) => (post.categories || []).includes('page')).length; + const pageRoutes = index.postsByCategory.get('page')?.length ?? 0; const categoryPages = Array.from(allCategories).reduce((sum, category) => { - const count = posts.filter((post) => (post.categories || []).includes(category)).length; + const count = index.postsByCategory.get(category)?.length ?? 0; return sum + this.countPaginatedPages(count, maxPostsPerPage); }, 0); const tagPages = Array.from(allTags).reduce((sum, tag) => { - const count = posts.filter((post) => (post.tags || []).includes(tag)).length; + const count = index.postsByTag.get(tag)?.length ?? 0; return sum + this.countPaginatedPages(count, maxPostsPerPage); }, 0); let datePages = 0; for (const [year] of yearsMap) { - const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year); - datePages += this.countPaginatedPages(yearPosts.length, maxPostsPerPage); + const count = index.postsByYear.get(year)?.length ?? 0; + datePages += this.countPaginatedPages(count, maxPostsPerPage); } for (const [ym] of yearMonthsMap) { - const [yearStr, monthStr] = ym.split('/'); - const year = Number(yearStr); - const month = Number(monthStr); - const monthPosts = posts.filter((post) => { - const d = resolvePostCreatedAt(post); - return d.getFullYear() === year && (d.getMonth() + 1) === month; - }); - datePages += this.countPaginatedPages(monthPosts.length, maxPostsPerPage); + const count = index.postsByYearMonth.get(ym)?.length ?? 0; + datePages += this.countPaginatedPages(count, maxPostsPerPage); } for (const [ymd] of yearMonthDaysMap) { - const [yearStr, monthStr, dayStr] = ymd.split('/'); - const year = Number(yearStr); - const month = Number(monthStr); - const day = Number(dayStr); - const dayPosts = posts.filter((post) => { - const d = resolvePostCreatedAt(post); - return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; - }); - datePages += this.countPaginatedPages(dayPosts.length, maxPostsPerPage); + const count = index.postsByYearMonthDay.get(ymd)?.length ?? 0; + datePages += this.countPaginatedPages(count, maxPostsPerPage); } return { @@ -1921,6 +1919,52 @@ export class BlogGenerationEngine { } return Math.max(1, Math.ceil(totalPosts / maxPostsPerPage)); } + + private buildGenerationPostIndex(posts: PostData[]): GenerationPostIndex { + const postsByCategory = new Map(); + const postsByTag = new Map(); + const postsByYear = new Map(); + const postsByYearMonth = new Map(); + const postsByYearMonthDay = new Map(); + + const append = (target: Map, key: TKey, post: PostData) => { + const existing = target.get(key); + if (existing) { + existing.push(post); + return; + } + target.set(key, [post]); + }; + + for (const post of posts) { + for (const category of post.categories || []) { + append(postsByCategory, category, post); + } + + for (const tag of post.tags || []) { + append(postsByTag, tag, post); + } + + const createdAt = resolvePostCreatedAt(post); + const year = createdAt.getFullYear(); + const month = String(createdAt.getMonth() + 1).padStart(2, '0'); + const day = String(createdAt.getDate()).padStart(2, '0'); + const ym = `${year}/${month}`; + const ymd = `${year}/${month}/${day}`; + + append(postsByYear, year, post); + append(postsByYearMonth, ym, post); + append(postsByYearMonthDay, ymd, post); + } + + return { + postsByCategory, + postsByTag, + postsByYear, + postsByYearMonth, + postsByYearMonthDay, + }; + } } let blogGenerationEngine: BlogGenerationEngine | null = null; diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index e59c727..fe7e582 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -177,25 +177,12 @@ export class PreviewServer { 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, - ), + pageRenderer: this.pageRenderer, + postEngineForMacros: this.postEngine, + loadPublishedSnapshotsPage: (filter, pagination) => this.loadPublishedSnapshotsPage(filter, pagination), + loadPublishedSnapshots: (filter, pagination) => this.loadPublishedSnapshots(filter, pagination), + loadPostsForDayPage: (year, month, day, pagination) => this.loadPostsForDayPage(year, month, day, pagination), + findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => this.findSinglePostBySlug(slug, singlePostOptions, dateFilter), }); } diff --git a/src/main/engine/SharedRouteRenderer.ts b/src/main/engine/SharedRouteRenderer.ts index c259e34..3b4dc25 100644 --- a/src/main/engine/SharedRouteRenderer.ts +++ b/src/main/engine/SharedRouteRenderer.ts @@ -4,10 +4,15 @@ import { getPicoStylesheetHref, sanitizePicoTheme } from '../shared/picoThemes'; import { buildTemplateMenuItems, clampMaxPostsPerPage, + parseRoutePagination, resolvePageTitle, + type PostEngineContract, type CategoryRenderSettings, type HtmlRewriteContext, + type PageRenderer, } from './PageRenderer'; +import type { CategoryMetadata } from './MetaEngine'; +import type { PostData, PostFilter } from './PostEngine'; export interface SharedActiveProjectContext { projectId: string; @@ -26,7 +31,7 @@ export interface SharedRouteRenderOptions { singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }; } -export interface SharedRouteRenderServices { +export interface SharedRouteRenderServices { postEngine: { setProjectContext: (projectId: string, dataDir?: string) => void; }; @@ -46,32 +51,221 @@ export interface SharedRouteRenderServices { setProjectContext: (projectId: string, dataDir?: string) => void; getMenu: () => Promise; }; - resolveCategoryMetadata: (metadata: ProjectMetadata | null) => Record; + resolveCategoryMetadata: (metadata: ProjectMetadata | null) => Record; resolveCategorySettings: (metadata: ProjectMetadata | null) => Record; resolveListExcludedCategories: (settings: Record) => string[]; buildHtmlRewriteContext: () => Promise; - resolveRoute: ( - pathname: string, - maxPostsPerPage: number, - rewriteContext: HtmlRewriteContext, - pageContext: { - pageTitle: string; - language: string; - menuItems: ReturnType; - picoStylesheetHref: string; - htmlThemeAttribute?: string; - }, - categorySettings: Record, - categoryMetadata: Record, - listExcludedCategories: string[], + pageRenderer: Pick; + postEngineForMacros?: PostEngineContract; + loadPublishedSnapshotsPage: ( + filter: PostFilter, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, + ) => Promise<{ posts: PostData[]; totalPosts: number }>; + loadPublishedSnapshots: ( + filter: PostFilter, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, + ) => Promise; + loadPostsForDayPage: ( + year: number, + month: number, + day: number, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, + ) => Promise<{ posts: PostData[]; totalPosts: number }>; + findSinglePostBySlug: ( + slug: string, singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, - ) => Promise; + dateFilter?: { year: number; month: number; day?: number }, + ) => Promise; } -export async function renderRouteWithSharedContext( +async function resolveRouteWithSharedServices( + pathname: string, + maxPostsPerPage: number, + rewriteContext: HtmlRewriteContext, + pageContext: { + pageTitle: string; + language: string; + menuItems: ReturnType; + picoStylesheetHref: string; + htmlThemeAttribute?: string; + }, + categorySettings: Record, + categoryMetadata: Record, + listExcludedCategories: string[], + services: SharedRouteRenderServices, + singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, +): Promise { + const routePagination = parseRoutePagination(pathname); + if (!routePagination) { + return null; + } + + const pagedPathname = routePagination.pathname; + const page = routePagination.page; + const pageOptions = { + maxPostsPerPage, + page, + }; + + if (pagedPathname === '/') { + const result = await services.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions); + return services.pageRenderer.renderPostList(result.posts, rewriteContext, { + archiveGrouping: true, + routeKind: 'date', + archiveContext: { kind: 'root' }, + basePathname: pagedPathname, + pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/); + if (tagMatch) { + const tag = tagMatch[1]; + const result = await services.loadPublishedSnapshotsPage({ status: 'published', tags: [tag], excludeCategories: listExcludedCategories }, pageOptions); + return services.pageRenderer.renderPostList(result.posts, rewriteContext, { + archiveGrouping: true, + routeKind: 'non-date', + archiveContext: { kind: 'tag', name: tag }, + basePathname: pagedPathname, + pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/); + if (categoryMatch) { + const category = categoryMatch[1]; + const categoryDisplayTitle = categoryMetadata[category]?.title?.trim() || category; + const result = await services.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions); + return services.pageRenderer.renderPostList(result.posts, rewriteContext, { + archiveGrouping: true, + routeKind: 'non-date', + archiveContext: { kind: 'category', name: categoryDisplayTitle }, + basePathname: pagedPathname, + pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + const daySlugMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/); + if (daySlugMatch) { + const year = Number(daySlugMatch[1]); + const month = Number(daySlugMatch[2]); + const day = Number(daySlugMatch[3]); + const slug = daySlugMatch[4]; + const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day }); + if (!post) return null; + return services.pageRenderer.renderSinglePost(post, rewriteContext, { + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + const dayMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})$/); + if (dayMatch) { + const year = Number(dayMatch[1]); + const month = Number(dayMatch[2]); + const day = Number(dayMatch[3]); + const result = await services.loadPostsForDayPage(year, month, day, { + ...pageOptions, + excludeCategories: listExcludedCategories, + }); + return services.pageRenderer.renderPostList(result.posts, rewriteContext, { + archiveGrouping: true, + routeKind: 'date', + archiveContext: { kind: 'day', year, month, day }, + basePathname: pagedPathname, + pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + const monthMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})$/); + if (monthMatch) { + const year = Number(monthMatch[1]); + const month = Number(monthMatch[2]); + if (month < 1 || month > 12) return null; + const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions); + return services.pageRenderer.renderPostList(result.posts, rewriteContext, { + archiveGrouping: true, + routeKind: 'date', + archiveContext: { kind: 'month', year, month }, + basePathname: pagedPathname, + pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + const yearMatch = pagedPathname.match(/^\/(\d{4})$/); + if (yearMatch) { + const year = Number(yearMatch[1]); + const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, excludeCategories: listExcludedCategories }, pageOptions); + return services.pageRenderer.renderPostList(result.posts, rewriteContext, { + archiveGrouping: true, + routeKind: 'date', + archiveContext: { kind: 'year', year }, + basePathname: pagedPathname, + pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/); + if (pageSlugMatch) { + const slug = pageSlugMatch[1]; + const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage }); + const pagePost = pages.find((candidate) => candidate.slug === slug) || null; + if (!pagePost) return null; + return services.pageRenderer.renderSinglePost(pagePost, rewriteContext, { + page_title: pageContext.pageTitle, + language: pageContext.language, + menu_items: pageContext.menuItems, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, services.postEngineForMacros); + } + + return null; +} + +export async function renderRouteWithSharedContext( pathname: string, options: SharedRouteRenderOptions, - services: SharedRouteRenderServices, + services: SharedRouteRenderServices, ): Promise { services.postEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir); services.mediaEngine.setProjectContext?.(options.projectContext.projectId, options.projectContext.dataDir, options.projectContext.dataDir); @@ -101,11 +295,11 @@ export async function renderRouteWithSharedContext( const htmlRewriteContext = await services.buildHtmlRewriteContext(); const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/'); - return services.resolveRoute(normalizedPathname, maxPostsPerPage, htmlRewriteContext, { + return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, { pageTitle, language, menuItems, picoStylesheetHref, htmlThemeAttribute: options.htmlThemeAttribute, - }, categorySettings, categoryMetadata, listExcludedCategories, options.singlePostOptions); + }, categorySettings, categoryMetadata as Record, listExcludedCategories, services as SharedRouteRenderServices, options.singlePostOptions); } diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index a9a84c7..9fbecde 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -640,6 +640,41 @@ describe('BlogGenerationEngine', () => { expect(filteredCallCount).toBeLessThanOrEqual(8); }); + it('reduces repeated in-memory filtering across category tag and date generation', async () => { + const posts: PostData[] = []; + for (let i = 0; i < 30; i += 1) { + const month = (i % 6) + 1; + const day = (i % 5) + 1; + posts.push(makePost({ + id: `perf-${i}`, + slug: `perf-${i}`, + categories: [`cat-${i % 10}`], + tags: [`tag-${i % 10}`], + createdAt: new Date(`2025-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T10:00:00Z`), + })); + } + + setupPosts(posts); + + const filterSpy = vi.spyOn(Array.prototype, 'filter'); + const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine'); + const engine = new BlogGenerationEngine(); + + await engine.generate({ + projectId: 'test', + projectName: 'Test Blog', + dataDir: tempDir, + baseUrl: 'https://example.com', + maxPostsPerPage: 5, + sections: ['category', 'tag', 'date'], + }, vi.fn()); + + const filterCallCount = filterSpy.mock.calls.length; + filterSpy.mockRestore(); + + expect(filterCallCount).toBeLessThanOrEqual(750); + }); + it('validates sitemap against html folder without rendering missing pages', async () => { const posts = [ makePost({