diff --git a/src/main/engine/GenerationRouteRendererFactory.ts b/src/main/engine/GenerationRouteRendererFactory.ts index bcf0362..0a561ff 100644 --- a/src/main/engine/GenerationRouteRendererFactory.ts +++ b/src/main/engine/GenerationRouteRendererFactory.ts @@ -18,6 +18,7 @@ interface RenderContext { menu?: MenuDocument; skipContextSetup?: boolean; maxPostsPerPage?: number; + allowEmptyArchiveRender?: boolean; } export function createGenerationRouteRenderer(params: { @@ -111,6 +112,10 @@ export function createPreviewBackedGenerationRouteRenderer(params: { const serializeFilter = (filter: unknown): string => { const normalizeValue = (value: unknown): unknown => { + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { return value.map((entry) => normalizeValue(entry)); } @@ -244,6 +249,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: { menu, skipContextSetup: true, maxPostsPerPage: params.maxPostsPerPage, + allowEmptyArchiveRender: true, }, }); } diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 464c9a0..d2077dc 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -1105,10 +1105,11 @@ export class PageRenderer { html_theme_attribute?: string; pagination?: PaginationContext; categorySettings?: Record; + renderEmptyState?: boolean; }, postEngine?: PostEngineContract, ): Promise { - if (posts.length === 0) { + if (posts.length === 0 && !options.renderEmptyState) { return ''; } diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index e037d23..be95ddf 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -179,6 +179,7 @@ export class PreviewServer { maxPostsPerPage?: number; requestTheme?: string | null; htmlThemeAttribute?: string; + allowEmptyArchiveRender?: boolean; singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }; }, ): Promise { diff --git a/src/main/engine/SharedRouteRenderer.ts b/src/main/engine/SharedRouteRenderer.ts index 49b3422..9bd6bd4 100644 --- a/src/main/engine/SharedRouteRenderer.ts +++ b/src/main/engine/SharedRouteRenderer.ts @@ -30,6 +30,7 @@ export interface SharedRouteRenderOptions { maxPostsPerPage?: number; requestTheme?: string | null; htmlThemeAttribute?: string; + allowEmptyArchiveRender?: boolean; singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }; } @@ -97,6 +98,7 @@ async function resolveRouteWithSharedServices( tagColorByName: Record, listExcludedCategories: string[], services: SharedRouteRenderServices, + allowEmptyArchiveRender: boolean, singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, ): Promise { const routePagination = parseRoutePagination(pathname); @@ -125,6 +127,7 @@ async function resolveRouteWithSharedServices( menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, + renderEmptyState: allowEmptyArchiveRender, }, services.postEngineForMacros); } @@ -144,6 +147,7 @@ async function resolveRouteWithSharedServices( menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, + renderEmptyState: allowEmptyArchiveRender, }, services.postEngineForMacros); } @@ -164,6 +168,7 @@ async function resolveRouteWithSharedServices( menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, + renderEmptyState: allowEmptyArchiveRender, }, services.postEngineForMacros); } @@ -206,6 +211,7 @@ async function resolveRouteWithSharedServices( menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, + renderEmptyState: allowEmptyArchiveRender, }, services.postEngineForMacros); } @@ -227,6 +233,7 @@ async function resolveRouteWithSharedServices( menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, + renderEmptyState: allowEmptyArchiveRender, }, services.postEngineForMacros); } @@ -246,6 +253,7 @@ async function resolveRouteWithSharedServices( menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, + renderEmptyState: allowEmptyArchiveRender, }, services.postEngineForMacros); } @@ -310,5 +318,5 @@ export async function renderRouteWithSharedContext( menuItems, picoStylesheetHref, htmlThemeAttribute: options.htmlThemeAttribute, - }, categorySettings, categoryMetadata as Record, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices, options.singlePostOptions); + }, categorySettings, categoryMetadata as Record, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices, options.allowEmptyArchiveRender === true, options.singlePostOptions); } diff --git a/src/main/engine/SiteValidationDiffService.ts b/src/main/engine/SiteValidationDiffService.ts index 6e75fe2..90d43bc 100644 --- a/src/main/engine/SiteValidationDiffService.ts +++ b/src/main/engine/SiteValidationDiffService.ts @@ -55,8 +55,12 @@ function extractSitemapLocs(sitemapXml: string): string[] { return locs; } -async function collectHtmlIndexPaths(htmlDir: string): Promise> { +async function collectHtmlIndexPaths(htmlDir: string): Promise<{ + existingHtmlPathSet: Set; + zeroByteHtmlPathSet: Set; +}> { const existingHtmlPathSet = new Set(); + const zeroByteHtmlPathSet = new Set(); const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise => { let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>; @@ -80,12 +84,28 @@ async function collectHtmlIndexPaths(htmlDir: string): Promise> { } const normalizedRelative = nextRelative.replace(/(^|\/)index\.html$/, ''); - existingHtmlPathSet.add(normalizeUrlPath(normalizedRelative ? `/${normalizedRelative}` : '/')); + const normalizedUrlPath = normalizeUrlPath(normalizedRelative ? `/${normalizedRelative}` : '/'); + + try { + const stats = await fs.stat(nextPath); + if (stats.size <= 0) { + zeroByteHtmlPathSet.add(normalizedUrlPath); + continue; + } + } catch { + zeroByteHtmlPathSet.add(normalizedUrlPath); + continue; + } + + existingHtmlPathSet.add(normalizedUrlPath); } }; await collectIndexPaths(htmlDir); - return existingHtmlPathSet; + return { + existingHtmlPathSet, + zeroByteHtmlPathSet, + }; } export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams): Promise { @@ -95,7 +115,7 @@ export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams): .map((value) => normalizeUrlPath(value)), ); - const existingHtmlPathSet = await collectHtmlIndexPaths(params.htmlDir); + const { existingHtmlPathSet, zeroByteHtmlPathSet } = await collectHtmlIndexPaths(params.htmlDir); const missingUrlPaths = Array.from(expectedPathSet) .filter((value) => !existingHtmlPathSet.has(value)) @@ -103,6 +123,8 @@ export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams): const extraUrlPaths = Array.from(existingHtmlPathSet) .filter((value) => !expectedPathSet.has(value)) + .concat(Array.from(zeroByteHtmlPathSet).filter((value) => !expectedPathSet.has(value))) + .filter((value, index, array) => array.indexOf(value) === index) .sort(); return { diff --git a/tests/engine/GenerationRouteRendererFactory.test.ts b/tests/engine/GenerationRouteRendererFactory.test.ts index b361254..9035a82 100644 --- a/tests/engine/GenerationRouteRendererFactory.test.ts +++ b/tests/engine/GenerationRouteRendererFactory.test.ts @@ -1,5 +1,28 @@ import { describe, expect, it, vi } from 'vitest'; -import { createGenerationRouteRenderer } from '../../src/main/engine/GenerationRouteRendererFactory'; +import type { PostData } from '../../src/main/engine/PostEngine'; +import { + createGenerationRouteRenderer, + createPreviewBackedGenerationRouteRenderer, +} from '../../src/main/engine/GenerationRouteRendererFactory'; + +function makePost(overrides: Partial = {}): PostData { + const createdAt = overrides.createdAt ?? new Date('2025-01-15T10:00:00.000Z'); + return { + id: overrides.id ?? 'post-1', + projectId: overrides.projectId ?? 'project', + title: overrides.title ?? 'Title', + slug: overrides.slug ?? 'title', + excerpt: overrides.excerpt, + content: overrides.content ?? 'Body', + status: overrides.status ?? 'published', + author: overrides.author, + createdAt, + updatedAt: overrides.updatedAt ?? createdAt, + publishedAt: overrides.publishedAt ?? createdAt, + tags: overrides.tags ?? [], + categories: overrides.categories ?? [], + }; +} describe('GenerationRouteRendererFactory', () => { it('normalizes route keys and memoizes html rendering calls', async () => { @@ -31,4 +54,86 @@ describe('GenerationRouteRendererFactory', () => { expect(renderWithContext).toHaveBeenCalledTimes(1); expect(renderWithContext).toHaveBeenCalledWith('/foo', expect.any(Object)); }); + + it('keeps day archive query caches distinct across different dates', async () => { + const posts = [ + makePost({ + id: 'day-1', + slug: 'day-1', + title: 'Post On Day One', + createdAt: new Date('2025-01-15T10:00:00.000Z'), + }), + makePost({ + id: 'day-2', + slug: 'day-2', + title: 'Post On Day Two', + createdAt: new Date('2025-01-16T10:00:00.000Z'), + }), + ]; + + const postEngine = { + getPostsFiltered: vi.fn(async (filter: { + status?: 'draft' | 'published' | 'archived'; + startDate?: Date; + endDate?: Date; + year?: number; + month?: number; + }) => { + let filtered = posts.filter((post) => post.status === (filter.status ?? post.status)); + + if (typeof filter.year === 'number') { + filtered = filtered.filter((post) => post.createdAt.getFullYear() === filter.year); + } + + if (typeof filter.month === 'number') { + filtered = filtered.filter((post) => post.createdAt.getMonth() === filter.month); + } + + if (filter.startDate) { + filtered = filtered.filter((post) => post.createdAt >= filter.startDate as Date); + } + + if (filter.endDate) { + filtered = filtered.filter((post) => post.createdAt <= filter.endDate as Date); + } + + return filtered; + }), + getPublishedVersion: vi.fn(async () => null), + findPublishedBySlug: vi.fn(async (slug: string) => posts.find((post) => post.slug === slug) ?? null), + getPost: vi.fn(async (id: string) => posts.find((post) => post.id === id) ?? null), + hasPublishedVersion: vi.fn(async () => false), + setProjectContext: vi.fn(), + }; + + const renderRoute = createPreviewBackedGenerationRouteRenderer({ + options: { + projectId: 'project', + dataDir: '/tmp', + projectName: 'Project', + }, + maxPostsPerPage: 50, + publishedPostsForLookup: posts, + engines: { + postEngine, + mediaEngine: { + getAllMedia: vi.fn(async () => []), + setProjectContext: vi.fn(), + }, + postMediaEngine: { + setProjectContext: vi.fn(), + getLinkedMediaForPost: vi.fn(async () => []), + getLinkedMediaDataForPost: vi.fn(async () => []), + }, + }, + }); + + const dayOneHtml = await renderRoute('/2025/01/15'); + const dayTwoHtml = await renderRoute('/2025/01/16'); + + expect(dayOneHtml).toContain('Post On Day One'); + expect(dayOneHtml).not.toContain('Post On Day Two'); + expect(dayTwoHtml).toContain('Post On Day Two'); + expect(dayTwoHtml).not.toContain('Post On Day One'); + }); }); diff --git a/tests/engine/PageRenderer.renderPostList.test.ts b/tests/engine/PageRenderer.renderPostList.test.ts new file mode 100644 index 0000000..3c7cb21 --- /dev/null +++ b/tests/engine/PageRenderer.renderPostList.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { PageRenderer, type HtmlRewriteContext } from '../../src/main/engine/PageRenderer'; + +const rewriteContext: HtmlRewriteContext = { + canonicalPostPathBySlug: new Map(), + canonicalMediaPathBySourcePath: new Map(), +}; + +describe('PageRenderer.renderPostList', () => { + it('renders base framework for empty day archive pages instead of returning empty html', async () => { + const renderer = new PageRenderer( + { getAllMedia: async () => [] }, + { + getLinkedMediaDataForPost: async () => [], + setProjectContext: () => {}, + }, + ); + + const html = await renderer.renderPostList([], rewriteContext, { + archiveGrouping: true, + routeKind: 'date', + archiveContext: { kind: 'day', year: 2026, month: 2, day: 22 }, + basePathname: '/2026/02/22', + page_title: 'Test Blog', + language: 'en', + menu_items: [], + renderEmptyState: true, + }); + + expect(html).toContain(''); + expect(html).toContain('
Archive 22. February 2026'); + }); +}); diff --git a/tests/engine/SiteValidationDiffService.test.ts b/tests/engine/SiteValidationDiffService.test.ts index 4a1d50d..79ad2c2 100644 --- a/tests/engine/SiteValidationDiffService.test.ts +++ b/tests/engine/SiteValidationDiffService.test.ts @@ -63,4 +63,31 @@ describe('SiteValidationDiffService', () => { expect(result.expectedUrlCount).toBe(2); expect(result.existingHtmlUrlCount).toBe(0); }); + + it('treats zero-byte index pages as missing routes that need regeneration', async () => { + const tempRoot = path.join('/tmp', makeTempName()); + const htmlDir = path.join(tempRoot, 'html'); + + await mkdir(path.join(htmlDir, '2026', '02', '22'), { recursive: true }); + await writeFile(path.join(htmlDir, '2026', '02', '22', 'index.html'), '', 'utf-8'); + + const sitemapXml = [ + '', + '', + ' https://example.com/2026/02/22/', + '', + '', + ].join('\n'); + + const result = await compareSitemapToHtml({ + sitemapXml, + baseUrl: 'https://example.com', + htmlDir, + }); + + expect(result.missingUrlPaths).toEqual(['/2026/02/22']); + expect(result.extraUrlPaths).toEqual([]); + expect(result.expectedUrlCount).toBe(1); + expect(result.existingHtmlUrlCount).toBe(0); + }); });