fix: 0-byte index.html day archives

This commit is contained in:
2026-02-22 15:34:16 +01:00
parent b4109d7210
commit a7e7ae5b1a
8 changed files with 211 additions and 7 deletions

View File

@@ -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,
},
});
}

View File

@@ -1105,10 +1105,11 @@ export class PageRenderer {
html_theme_attribute?: string;
pagination?: PaginationContext;
categorySettings?: Record<string, CategoryRenderSettings>;
renderEmptyState?: boolean;
},
postEngine?: PostEngineContract,
): Promise<string> {
if (posts.length === 0) {
if (posts.length === 0 && !options.renderEmptyState) {
return '';
}

View File

@@ -179,6 +179,7 @@ export class PreviewServer {
maxPostsPerPage?: number;
requestTheme?: string | null;
htmlThemeAttribute?: string;
allowEmptyArchiveRender?: boolean;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
},
): Promise<string | null> {

View File

@@ -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<string, string>,
listExcludedCategories: string[],
services: SharedRouteRenderServices<CategoryMetadata>,
allowEmptyArchiveRender: boolean,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
): Promise<string | null> {
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<TCategoryMetadata>(
menuItems,
picoStylesheetHref,
htmlThemeAttribute: options.htmlThemeAttribute,
}, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.singlePostOptions);
}, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.allowEmptyArchiveRender === true, options.singlePostOptions);
}

View File

@@ -55,8 +55,12 @@ function extractSitemapLocs(sitemapXml: string): string[] {
return locs;
}
async function collectHtmlIndexPaths(htmlDir: string): Promise<Set<string>> {
async function collectHtmlIndexPaths(htmlDir: string): Promise<{
existingHtmlPathSet: Set<string>;
zeroByteHtmlPathSet: Set<string>;
}> {
const existingHtmlPathSet = new Set<string>();
const zeroByteHtmlPathSet = new Set<string>();
const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise<void> => {
let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>;
@@ -80,12 +84,28 @@ async function collectHtmlIndexPaths(htmlDir: string): Promise<Set<string>> {
}
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<SiteValidationDiffResult> {
@@ -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 {

View File

@@ -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> = {}): 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');
});
});

View File

@@ -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<string, string>(),
canonicalMediaPathBySourcePath: new Map<string, string>(),
};
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('<!doctype html>');
expect(html).toContain('<section class="post-list"');
expect(html).toContain('<h1 class="archive-heading">Archive 22. February 2026</h1>');
});
});

View File

@@ -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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
' <url><loc>https://example.com/2026/02/22/</loc></url>',
'</urlset>',
'',
].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);
});
});