chore: refactorings and code sharing

This commit is contained in:
2026-02-22 09:20:22 +01:00
parent 653e79dd70
commit 2a73db57b4
12 changed files with 1587 additions and 1158 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
import type { PostData } from './PostEngine';
export interface GenerationSnapshotPostEngine {
getPostsFiltered: (filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }) => Promise<PostData[]>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
}
export interface GenerationPublishedSets {
publishedPosts: PostData[];
publishedListPosts: PostData[];
}
export async function loadPublishedGenerationSets(
postEngine: GenerationSnapshotPostEngine,
listExcludedCategories: string[],
): Promise<GenerationPublishedSets> {
const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' });
const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
const publishedListCandidates = await postEngine.getPostsFiltered({
status: 'published',
excludeCategories: listExcludedCategories,
});
const draftListCandidates = await postEngine.getPostsFiltered({
status: 'draft',
excludeCategories: listExcludedCategories,
});
const publishedSnapshots = await Promise.all(
publishedCandidates.map(async (post) => {
const snapshot = await postEngine.getPublishedVersion(post.id);
return snapshot || post;
}),
);
const draftPublishedSnapshots = await Promise.all(
draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
);
const publishedListSnapshots = await Promise.all(
publishedListCandidates.map(async (post) => {
const snapshot = await postEngine.getPublishedVersion(post.id);
return snapshot || post;
}),
);
const draftListPublishedSnapshots = await Promise.all(
draftListCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
);
const publishedPostById = new Map<string, PostData>();
for (const post of publishedSnapshots) {
publishedPostById.set(post.id, post);
}
for (const snapshot of draftPublishedSnapshots) {
if (snapshot) {
publishedPostById.set(snapshot.id, snapshot);
}
}
const publishedListPostById = new Map<string, PostData>();
for (const post of publishedListSnapshots) {
publishedListPostById.set(post.id, post);
}
for (const snapshot of draftListPublishedSnapshots) {
if (snapshot) {
publishedListPostById.set(snapshot.id, snapshot);
}
}
return {
publishedPosts: Array.from(publishedPostById.values())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
publishedListPosts: Array.from(publishedListPostById.values())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
};
}

View File

@@ -0,0 +1,375 @@
import type { PostData } from './PostEngine';
export interface GenerationPostIndexLike {
postsByCategory: Map<string, PostData[]>;
postsByTag: Map<string, PostData[]>;
postsByYear: Map<number, PostData[]>;
postsByYearMonth: Map<string, PostData[]>;
postsByYearMonthDay: Map<string, PostData[]>;
}
interface BuildSitemapAndFeedsParams {
baseUrl: string;
projectName: string;
projectDescription?: string;
maxPostsPerPage: number;
publishedPosts: PostData[];
publishedListPosts: PostData[];
postIndex: GenerationPostIndexLike;
includeFeeds: boolean;
}
export interface SitemapFeedBuildResult {
allTags: Set<string>;
allCategories: Set<string>;
yearMonths: Map<string, Date>;
years: Map<number, Date>;
yearMonthDays: Map<string, Date>;
urls: string[];
sitemapXml: string;
rssXml: string;
atomXml: string;
feedPosts: PostData[];
}
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
if (post.createdAt instanceof Date) {
return post.createdAt;
}
const parsed = new Date(post.createdAt);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
return `/${year}/${month}/${day}/${slug}`;
}
function escapeXml(value: unknown): string {
const str = typeof value === 'string' ? value : value == null ? '' : String(value);
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function buildSitemapUrl(
loc: string,
lastmod: string,
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
priority: string,
): string {
const canonicalLoc = (() => {
try {
const parsed = new URL(loc);
if (!parsed.pathname.endsWith('/')) {
parsed.pathname = `${parsed.pathname}/`;
}
return parsed.toString();
} catch {
return loc.endsWith('/') ? loc : `${loc}/`;
}
})();
return [
' <url>',
` <loc>${escapeXml(canonicalLoc)}</loc>`,
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
` <changefreq>${changefreq}</changefreq>`,
` <priority>${priority}</priority>`,
' </url>',
].join('\n');
}
function appendPaginatedSitemapUrls(
target: string[],
baseUrl: string,
basePath: string,
totalItems: number,
maxPostsPerPage: number,
lastmod: string,
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
priority: string,
): void {
if (totalItems <= 0) {
return;
}
const totalPages = Math.max(1, Math.ceil(totalItems / maxPostsPerPage));
for (let page = 2; page <= totalPages; page += 1) {
const normalizedBase = basePath.replace(/\/+$/, '');
const pagePath = `${normalizedBase}/page/${page}`;
target.push(buildSitemapUrl(`${baseUrl}${pagePath}`, lastmod, changefreq, priority));
}
}
function splitParagraphs(markdown: string | null | undefined): string[] {
const normalizedMarkdown = typeof markdown === 'string' ? markdown : '';
return normalizedMarkdown
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter((paragraph) => paragraph.length > 0);
}
function paragraphToXhtml(paragraph: string): string {
const escaped = escapeXml(paragraph).replace(/\n/g, '<br />');
return `<p>${escaped}</p>`;
}
function markdownToXhtml(markdown: string): string {
const paragraphs = splitParagraphs(markdown);
if (paragraphs.length === 0) {
return '<p></p>';
}
return paragraphs.map(paragraphToXhtml).join('');
}
function excerptToXhtml(post: PostData): string {
if (typeof post.excerpt === 'string' && post.excerpt.trim().length > 0) {
return paragraphToXhtml(post.excerpt.trim());
}
const firstParagraph = splitParagraphs(post.content)[0] || '';
return paragraphToXhtml(firstParagraph);
}
function escapeCdata(value: string): string {
return value.replace(/]]>/g, ']]]]><![CDATA[>');
}
export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): SitemapFeedBuildResult {
const {
baseUrl,
projectName,
projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex,
includeFeeds,
} = params;
const now = new Date().toISOString();
const allTags = new Set<string>();
const allCategories = new Set<string>();
const yearMonths = new Map<string, Date>();
const years = new Map<number, Date>();
const yearMonthDays = new Map<string, Date>();
const postUrls: Array<{ loc: string; lastmod: string }> = [];
const pageUrls: Array<{ loc: string; lastmod: string }> = [];
for (const post of publishedPosts) {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const postUrl = `${baseUrl}${canonicalPath}`;
const updatedAt = post.updatedAt;
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
const categories = Array.isArray(post.categories) ? post.categories : [];
if (categories.includes('page')) {
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
if (trimmedSlug.length > 0) {
pageUrls.push({
loc: `${baseUrl}/${trimmedSlug}`,
lastmod: updatedAt.toISOString(),
});
}
}
}
for (const post of publishedListPosts) {
for (const tag of post.tags || []) allTags.add(tag);
for (const category of post.categories || []) allCategories.add(category);
const createdAt = resolvePostCreatedAt(post);
const updatedAt = post.updatedAt;
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
const ymKey = `${year}/${month}`;
const ymdKey = `${year}/${month}/${day}`;
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
yearMonths.set(ymKey, updatedAt);
}
if (!years.has(year) || updatedAt > years.get(year)!) {
years.set(year, updatedAt);
}
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
yearMonthDays.set(ymdKey, updatedAt);
}
}
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
const urls: string[] = [];
urls.push(buildSitemapUrl(`${baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
appendPaginatedSitemapUrls(urls, baseUrl, '', publishedListPosts.length, maxPostsPerPage, latestPostUpdatedAt, 'daily', '0.9');
for (const post of postUrls) {
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
}
for (const page of pageUrls) {
urls.push(buildSitemapUrl(page.loc, page.lastmod, 'weekly', '0.7'));
}
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
urls.push(buildSitemapUrl(`${baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
const yearCount = postIndex.postsByYear.get(year)?.length ?? 0;
appendPaginatedSitemapUrls(urls, baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
}
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
const monthCount = postIndex.postsByYearMonth.get(ym)?.length ?? 0;
appendPaginatedSitemapUrls(urls, baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
}
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
urls.push(buildSitemapUrl(`${baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
const dayCount = postIndex.postsByYearMonthDay.get(ymd)?.length ?? 0;
appendPaginatedSitemapUrls(urls, baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3');
}
for (const category of Array.from(allCategories).sort()) {
urls.push(buildSitemapUrl(`${baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
const categoryCount = postIndex.postsByCategory.get(category)?.length ?? 0;
appendPaginatedSitemapUrls(urls, baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
}
for (const tag of Array.from(allTags).sort()) {
urls.push(buildSitemapUrl(`${baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
const tagCount = postIndex.postsByTag.get(tag)?.length ?? 0;
appendPaginatedSitemapUrls(urls, baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
}
const sitemapXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urls,
'</urlset>',
'',
].join('\n');
const feedPosts = publishedListPosts.slice(0, maxPostsPerPage);
if (!includeFeeds) {
return {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
urls,
sitemapXml,
rssXml: '',
atomXml: '',
feedPosts,
};
}
const feedUpdatedAt = feedPosts[0]?.updatedAt || new Date();
const baseLink = `${baseUrl}/`;
const feedTitle = projectName;
const feedDescription = projectDescription?.trim() || feedTitle;
const rssItems = feedPosts.map((post) => {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || '');
const categories = [
...(post.categories || []).map((category) => `<category>${escapeXml(category)}</category>`),
...(post.tags || []).map((tag) => `<category>${escapeXml(tag)}</category>`),
];
return [
' <item>',
` <title>${escapeXml(post.title)}</title>`,
` <link>${escapeXml(permalink)}</link>`,
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`,
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
...categories.map((entry) => ` ${entry}`),
' </item>',
].filter(Boolean).join('\n');
});
const rssXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
' <channel>',
` <title>${escapeXml(feedTitle)}</title>`,
` <link>${escapeXml(baseLink)}</link>`,
` <description>${escapeXml(feedDescription)}</description>`,
` <lastBuildDate>${feedUpdatedAt.toUTCString()}</lastBuildDate>`,
' <generator>bDS</generator>',
...rssItems,
' </channel>',
'</rss>',
'',
].join('\n');
const atomEntries = feedPosts.map((post) => {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || '');
const categories = [
...(post.tags || []).map((tag) => `<category term="${escapeXml(tag)}" />`),
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
];
return [
' <entry>',
` <title>${escapeXml(post.title)}</title>`,
` <id>${escapeXml(permalink)}</id>`,
` <link href="${escapeXml(permalink)}" />`,
` <updated>${post.updatedAt.toISOString()}</updated>`,
` <published>${(post.publishedAt || post.updatedAt).toISOString()}</published>`,
post.author ? ` <author><name>${escapeXml(post.author)}</name></author>` : null,
` <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${excerptXhtml}</div></summary>`,
` <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${contentXhtml}</div></content>`,
...categories.map((entry) => ` ${entry}`),
' </entry>',
].filter(Boolean).join('\n');
});
const atomXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<feed xmlns="http://www.w3.org/2005/Atom">',
` <title>${escapeXml(feedTitle)}</title>`,
` <subtitle>${escapeXml(feedDescription)}</subtitle>`,
` <id>${escapeXml(baseLink)}</id>`,
` <link href="${escapeXml(baseLink)}" rel="alternate" />`,
` <link href="${escapeXml(`${baseLink}atom.xml`)}" rel="self" />`,
` <updated>${feedUpdatedAt.toISOString()}</updated>`,
...atomEntries,
'</feed>',
'',
].join('\n');
return {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
urls,
sitemapXml,
rssXml,
atomXml,
feedPosts,
};
}

View File

@@ -14,7 +14,6 @@ import {
buildTemplateMenuItems, buildTemplateMenuItems,
buildCanonicalPostPath, buildCanonicalPostPath,
clampMaxPostsPerPage, clampMaxPostsPerPage,
parseRoutePagination,
resolvePageTitle, resolvePageTitle,
type CategoryRenderSettings, type CategoryRenderSettings,
type HtmlRewriteContext, type HtmlRewriteContext,
@@ -23,6 +22,12 @@ import {
} from './PageRenderer'; } from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
import { renderRouteWithSharedContext } from './SharedRouteRenderer'; import { renderRouteWithSharedContext } from './SharedRouteRenderer';
import {
findSinglePostBySlug,
loadPostsForDayPage,
loadPublishedSnapshots,
loadPublishedSnapshotsPage,
} from './SharedSnapshotService';
interface ActiveProjectContext { interface ActiveProjectContext {
projectId: string; projectId: string;
@@ -179,10 +184,10 @@ export class PreviewServer {
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(), buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
pageRenderer: this.pageRenderer, pageRenderer: this.pageRenderer,
postEngineForMacros: this.postEngine, postEngineForMacros: this.postEngine,
loadPublishedSnapshotsPage: (filter, pagination) => this.loadPublishedSnapshotsPage(filter, pagination), loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
loadPublishedSnapshots: (filter, pagination) => this.loadPublishedSnapshots(filter, pagination), loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
loadPostsForDayPage: (year, month, day, pagination) => this.loadPostsForDayPage(year, month, day, pagination), loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => this.findSinglePostBySlug(slug, singlePostOptions, dateFilter), findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter),
}); });
} }
@@ -294,190 +299,13 @@ export class PreviewServer {
} }
} }
private async 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> {
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 this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions);
return this.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,
}, this.postEngine);
}
const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/);
if (tagMatch) {
const tag = tagMatch[1];
const result = await this.loadPublishedSnapshotsPage({ status: 'published', tags: [tag], excludeCategories: listExcludedCategories }, pageOptions);
return this.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,
}, this.postEngine);
}
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
if (categoryMatch) {
const category = categoryMatch[1];
const categoryDisplayTitle = categoryMetadata[category]?.title?.trim() || category;
const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions);
return this.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,
}, this.postEngine);
}
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 this.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day });
if (!post) return null;
return this.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,
}, this.postEngine);
}
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 this.loadPostsForDayPage(year, month, day, {
...pageOptions,
excludeCategories: listExcludedCategories,
});
return this.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,
}, this.postEngine);
}
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 this.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions);
return this.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,
}, this.postEngine);
}
const yearMatch = pagedPathname.match(/^\/(\d{4})$/);
if (yearMatch) {
const year = Number(yearMatch[1]);
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, excludeCategories: listExcludedCategories }, pageOptions);
return this.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,
}, this.postEngine);
}
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
if (pageSlugMatch) {
const slug = pageSlugMatch[1];
const pages = await this.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
if (!pagePost) return null;
return this.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,
}, this.postEngine);
}
return null;
}
private async renderStylePreview( private async renderStylePreview(
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string }, pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>, categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[], listExcludedCategories: string[],
): Promise<string> { ): Promise<string> {
const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, { const result = await loadPublishedSnapshotsPage(this.postEngine, { status: 'published', excludeCategories: listExcludedCategories }, {
maxPostsPerPage: 10, maxPostsPerPage: 10,
page: 1, page: 1,
}); });
@@ -507,190 +335,8 @@ export class PreviewServer {
}, this.postEngine); }, this.postEngine);
} }
private async findPublishedPostBySlug(slug: string, dateFilter?: { year: number; month: number }): Promise<PostData | null> {
if (!slug) return null;
if (this.postEngine.findPublishedBySlug) {
const directMatch = await this.postEngine.findPublishedBySlug(slug, dateFilter);
if (directMatch) {
return directMatch;
}
}
const filter: PostFilter = {
...(dateFilter ? { year: dateFilter.year, month: dateFilter.month } : {}),
};
const candidates = await this.loadPublishedSnapshots(filter);
const match = candidates.find((candidate) => candidate.slug === slug);
if (!match) return null;
return match;
}
private async findSinglePostBySlug(
slug: string,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
dateFilter?: { year: number; month: number; day?: number },
): Promise<PostData | null> {
if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) {
const draftCandidate = await this.postEngine.getPost(singlePostOptions.draftPostId);
if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) {
if (!dateFilter) {
return draftCandidate;
}
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
return draftCandidate;
}
}
}
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
return this.findPublishedPostBySlug(slug, fallbackDateFilter);
}
private async loadPostsForDay(
year: number,
month: number,
day: number,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<PostData[]> {
const result = await this.loadPostsForDayPage(year, month, day, pagination);
return result.posts;
}
private async loadPostsForDayPage(
year: number,
month: number,
day: number,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<{ posts: PostData[]; totalPosts: number }> {
if (month < 1 || month > 12 || day < 1 || day > 31) {
return { posts: [], totalPosts: 0 };
}
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0);
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
const result = await this.loadPublishedSnapshotsPage({
status: 'published',
excludeCategories: pagination?.excludeCategories,
startDate,
endDate,
}, pagination);
const posts = result.posts.filter((post) => {
const createdAt = post.createdAt;
return createdAt.getFullYear() === year
&& createdAt.getMonth() === month - 1
&& createdAt.getDate() === day;
});
return {
posts,
totalPosts: result.totalPosts,
};
}
private buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
const baseFilter: PostFilter = {};
if (filter.startDate) baseFilter.startDate = filter.startDate;
if (filter.endDate) baseFilter.endDate = filter.endDate;
if (filter.year !== undefined) baseFilter.year = filter.year;
if (filter.month !== undefined) baseFilter.month = filter.month;
return baseFilter;
}
private async toPublishedSnapshot(post: PostData): Promise<PostData | null> {
if (post.status === 'published') {
return post;
}
if (post.status === 'draft') {
return await this.postEngine.getPublishedVersion(post.id);
}
return null;
}
private async loadPublishedSnapshots(
filter: PostFilter,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<PostData[]> {
const result = await this.loadPublishedSnapshotsPage(filter, pagination);
return result.posts;
}
private paginateSnapshots(
snapshots: PostData[],
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): { posts: PostData[]; totalPosts: number } {
const totalPosts = snapshots.length;
if (typeof pagination?.maxPostsPerPage !== 'number') {
return { posts: snapshots, totalPosts };
}
const maxPostsPerPage = pagination.maxPostsPerPage;
const page = Number.isInteger(pagination.page) && (pagination.page ?? 0) > 0
? (pagination.page as number)
: 1;
const offset = (page - 1) * maxPostsPerPage;
return {
posts: snapshots.slice(offset, offset + maxPostsPerPage),
totalPosts,
};
}
private async loadPublishedSnapshotsPage(
filter: PostFilter,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<{ posts: PostData[]; totalPosts: number }> {
if (filter.status && filter.status !== 'published') {
return { posts: [], totalPosts: 0 };
}
const baseFilter = this.buildSnapshotBaseFilter(filter);
const publishedCandidates = await this.postEngine.getPostsFiltered({
...baseFilter,
status: 'published',
excludeCategories: filter.excludeCategories,
});
const draftCandidates = await this.postEngine.getPostsFiltered({
...baseFilter,
status: 'draft',
excludeCategories: filter.excludeCategories,
});
const snapshotCandidates = await Promise.all([
...publishedCandidates.map((post) => this.toPublishedSnapshot(post)),
...draftCandidates.map((post) => this.toPublishedSnapshot(post)),
]);
let snapshots = snapshotCandidates.filter((post): post is PostData => post !== null);
if (filter.tags && filter.tags.length > 0) {
snapshots = snapshots.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag)));
}
if (filter.categories && filter.categories.length > 0) {
snapshots = snapshots.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
}
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return this.paginateSnapshots(snapshots, pagination);
}
private async buildHtmlRewriteContext(): Promise<HtmlRewriteContext> { private async buildHtmlRewriteContext(): Promise<HtmlRewriteContext> {
const publishedPosts = await this.loadPublishedSnapshots({ status: 'published' }); const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' });
const canonicalPostPathBySlug = new Map<string, string>(); const canonicalPostPathBySlug = new Map<string, string>();
for (const post of publishedPosts) { for (const post of publishedPosts) {

View File

@@ -0,0 +1,190 @@
import type { PostData, PostFilter } from './PostEngine';
export interface SharedSnapshotPostEngine {
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPost: (id: string) => Promise<PostData | null>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
}
function buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
const baseFilter: PostFilter = {};
if (filter.startDate) baseFilter.startDate = filter.startDate;
if (filter.endDate) baseFilter.endDate = filter.endDate;
if (filter.year !== undefined) baseFilter.year = filter.year;
if (filter.month !== undefined) baseFilter.month = filter.month;
return baseFilter;
}
async function toPublishedSnapshot(postEngine: SharedSnapshotPostEngine, post: PostData): Promise<PostData | null> {
if (post.status === 'published') {
return post;
}
if (post.status === 'draft') {
return await postEngine.getPublishedVersion(post.id);
}
return null;
}
function paginateSnapshots(
snapshots: PostData[],
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): { posts: PostData[]; totalPosts: number } {
const totalPosts = snapshots.length;
if (typeof pagination?.maxPostsPerPage !== 'number') {
return { posts: snapshots, totalPosts };
}
const maxPostsPerPage = pagination.maxPostsPerPage;
const page = Number.isInteger(pagination.page) && (pagination.page ?? 0) > 0
? (pagination.page as number)
: 1;
const offset = (page - 1) * maxPostsPerPage;
return {
posts: snapshots.slice(offset, offset + maxPostsPerPage),
totalPosts,
};
}
export async function loadPublishedSnapshotsPage(
postEngine: SharedSnapshotPostEngine,
filter: PostFilter,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<{ posts: PostData[]; totalPosts: number }> {
if (filter.status && filter.status !== 'published') {
return { posts: [], totalPosts: 0 };
}
const baseFilter = buildSnapshotBaseFilter(filter);
const publishedCandidates = await postEngine.getPostsFiltered({
...baseFilter,
status: 'published',
excludeCategories: filter.excludeCategories,
});
const draftCandidates = await postEngine.getPostsFiltered({
...baseFilter,
status: 'draft',
excludeCategories: filter.excludeCategories,
});
const snapshotCandidates = await Promise.all([
...publishedCandidates.map((post) => toPublishedSnapshot(postEngine, post)),
...draftCandidates.map((post) => toPublishedSnapshot(postEngine, post)),
]);
let snapshots = snapshotCandidates.filter((post): post is PostData => post !== null);
if (filter.tags && filter.tags.length > 0) {
snapshots = snapshots.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag)));
}
if (filter.categories && filter.categories.length > 0) {
snapshots = snapshots.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
}
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return paginateSnapshots(snapshots, pagination);
}
export async function loadPublishedSnapshots(
postEngine: SharedSnapshotPostEngine,
filter: PostFilter,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<PostData[]> {
const result = await loadPublishedSnapshotsPage(postEngine, filter, pagination);
return result.posts;
}
export async function loadPostsForDayPage(
postEngine: SharedSnapshotPostEngine,
year: number,
month: number,
day: number,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<{ posts: PostData[]; totalPosts: number }> {
if (month < 1 || month > 12 || day < 1 || day > 31) {
return { posts: [], totalPosts: 0 };
}
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0);
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
const result = await loadPublishedSnapshotsPage(
postEngine,
{
status: 'published',
excludeCategories: pagination?.excludeCategories,
startDate,
endDate,
},
pagination,
);
const posts = result.posts.filter((post) => {
const createdAt = post.createdAt;
return createdAt.getFullYear() === year
&& createdAt.getMonth() === month - 1
&& createdAt.getDate() === day;
});
return {
posts,
totalPosts: result.totalPosts,
};
}
export async function findPublishedPostBySlug(
postEngine: SharedSnapshotPostEngine,
slug: string,
dateFilter?: { year: number; month: number },
): Promise<PostData | null> {
if (!slug) return null;
if (postEngine.findPublishedBySlug) {
const directMatch = await postEngine.findPublishedBySlug(slug, dateFilter);
if (directMatch) {
return directMatch;
}
}
const filter: PostFilter = {
...(dateFilter ? { year: dateFilter.year, month: dateFilter.month } : {}),
};
const candidates = await loadPublishedSnapshots(postEngine, filter);
const match = candidates.find((candidate) => candidate.slug === slug);
return match ?? null;
}
export async function findSinglePostBySlug(
postEngine: SharedSnapshotPostEngine,
slug: string,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
dateFilter?: { year: number; month: number; day?: number },
): Promise<PostData | null> {
if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) {
const draftCandidate = await postEngine.getPost(singlePostOptions.draftPostId);
if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) {
if (!dateFilter) {
return draftCandidate;
}
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
return draftCandidate;
}
}
}
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
return findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
}

View File

@@ -0,0 +1,114 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
export interface SiteValidationDiffResult {
missingUrlPaths: string[];
extraUrlPaths: string[];
expectedUrlCount: number;
existingHtmlUrlCount: number;
}
interface CompareSitemapToHtmlParams {
sitemapXml: string;
baseUrl: string;
htmlDir: string;
}
function normalizeUrlPath(urlPath: string): string {
const trimmed = (urlPath || '').trim();
if (!trimmed || trimmed === '/') {
return '/';
}
const noQuery = trimmed.split('?')[0]?.split('#')[0] ?? '';
const withoutSlashes = noQuery.replace(/^\/+|\/+$/g, '');
return withoutSlashes ? `/${withoutSlashes}` : '/';
}
function sitemapLocToProjectPath(loc: string, baseUrl: string): string {
try {
const locUrl = new URL(loc);
const base = new URL(baseUrl);
const locPath = locUrl.pathname.replace(/\/+$/, '');
const basePath = base.pathname.replace(/\/+$/, '');
if (basePath && locPath.startsWith(basePath)) {
const stripped = locPath.slice(basePath.length);
return normalizeUrlPath(stripped || '/');
}
return normalizeUrlPath(locPath || '/');
} catch {
return normalizeUrlPath(loc);
}
}
function extractSitemapLocs(sitemapXml: string): string[] {
const matches = sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g);
const locs: string[] = [];
for (const match of matches) {
const value = match[1]?.trim();
if (value) {
locs.push(value);
}
}
return locs;
}
async function collectHtmlIndexPaths(htmlDir: string): Promise<Set<string>> {
const existingHtmlPathSet = new Set<string>();
const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise<void> => {
let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>;
try {
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' });
} catch {
return;
}
for (const entry of entries) {
const nextRelative = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
const nextPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await collectIndexPaths(nextPath, nextRelative);
continue;
}
if (!entry.isFile() || entry.name !== 'index.html') {
continue;
}
const normalizedRelative = nextRelative.replace(/(^|\/)index\.html$/, '');
existingHtmlPathSet.add(normalizeUrlPath(normalizedRelative ? `/${normalizedRelative}` : '/'));
}
};
await collectIndexPaths(htmlDir);
return existingHtmlPathSet;
}
export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams): Promise<SiteValidationDiffResult> {
const expectedPathSet = new Set(
extractSitemapLocs(params.sitemapXml)
.map((loc) => sitemapLocToProjectPath(loc, params.baseUrl))
.map((value) => normalizeUrlPath(value)),
);
const existingHtmlPathSet = await collectHtmlIndexPaths(params.htmlDir);
const missingUrlPaths = Array.from(expectedPathSet)
.filter((value) => !existingHtmlPathSet.has(value))
.sort();
const extraUrlPaths = Array.from(existingHtmlPathSet)
.filter((value) => !expectedPathSet.has(value))
.sort();
return {
missingUrlPaths,
extraUrlPaths,
expectedUrlCount: expectedPathSet.size,
existingHtmlUrlCount: existingHtmlPathSet.size,
};
}

View File

@@ -0,0 +1,243 @@
import type { PostData } from './PostEngine';
export interface RequestedPostRoute {
year: number;
month: number;
day: number;
slug: string;
}
export interface MissingPathPlan {
requestedCategories: Set<string>;
requestedTags: Set<string>;
requestedYears: Set<number>;
requestedYearMonths: Set<string>;
requestedYearMonthDays: Set<string>;
requestedPostRoutes: RequestedPostRoute[];
requestedPageSlugs: Set<string>;
requestRootRoutes: boolean;
requiresFallbackSectionRender: boolean;
}
export interface TargetedValidationPlan {
requestedPostIds: Set<string>;
requestedCategorySet: Set<string>;
requestedTagSet: Set<string>;
requestedYears: Set<number>;
requestedYearMonths: Set<string>;
requestedYearMonthDays: Set<string>;
requestedPageSlugs: Set<string>;
requestRootRoutes: boolean;
}
function normalizeUrlPath(urlPath: string): string {
const trimmed = (urlPath || '').trim();
if (!trimmed || trimmed === '/') {
return '/';
}
const noQuery = trimmed.split('?')[0]?.split('#')[0] ?? '';
const withoutSlashes = noQuery.replace(/^\/+|\/+$/g, '');
return withoutSlashes ? `/${withoutSlashes}` : '/';
}
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
if (post.createdAt instanceof Date) {
return post.createdAt;
}
const parsed = new Date(post.createdAt);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function planMissingValidationPaths(missingPaths: string[]): MissingPathPlan {
const requestedCategories = new Set<string>();
const requestedTags = new Set<string>();
const requestedYears = new Set<number>();
const requestedYearMonths = new Set<string>();
const requestedYearMonthDays = new Set<string>();
const requestedPostRoutes: RequestedPostRoute[] = [];
const requestedPageSlugs = new Set<string>();
let requestRootRoutes = false;
let requiresFallbackSectionRender = false;
for (const missingPath of missingPaths) {
const normalizedPath = normalizeUrlPath(missingPath);
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
requestRootRoutes = true;
continue;
}
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
if (categoryMatch) {
requestedCategories.add(decodePathSegment(categoryMatch[1]));
continue;
}
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
if (tagMatch) {
requestedTags.add(decodePathSegment(tagMatch[1]));
continue;
}
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
if (singleMatch) {
requestedPostRoutes.push({
year: Number(singleMatch[1]),
month: Number(singleMatch[2]),
day: Number(singleMatch[3]),
slug: decodePathSegment(singleMatch[4]),
});
continue;
}
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
if (yearMatch) {
requestedYears.add(Number(yearMatch[1]));
continue;
}
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
if (monthMatch) {
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
continue;
}
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
if (dayMatch) {
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
continue;
}
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
if (pageMatch) {
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
continue;
}
requiresFallbackSectionRender = true;
break;
}
return {
requestedCategories,
requestedTags,
requestedYears,
requestedYearMonths,
requestedYearMonthDays,
requestedPostRoutes,
requestedPageSlugs,
requestRootRoutes,
requiresFallbackSectionRender,
};
}
interface BuildTargetedValidationPlanParams {
initialPlan: MissingPathPlan;
publishedPosts: PostData[];
allCategories: Set<string>;
allTags: Set<string>;
availableYearMonths: Iterable<string>;
availableYearMonthDays: Iterable<string>;
}
export function buildTargetedValidationPlan(params: BuildTargetedValidationPlanParams): TargetedValidationPlan {
const {
initialPlan,
publishedPosts,
allCategories,
allTags,
availableYearMonths,
availableYearMonthDays,
} = params;
const requestedCategories = new Set(initialPlan.requestedCategories);
const requestedTags = new Set(initialPlan.requestedTags);
const requestedYears = new Set(initialPlan.requestedYears);
const requestedYearMonths = new Set(initialPlan.requestedYearMonths);
const requestedYearMonthDays = new Set(initialPlan.requestedYearMonthDays);
const requestedPostIds = new Set<string>();
for (const requestedRoute of initialPlan.requestedPostRoutes) {
const routePost = publishedPosts.find((post) => {
if (post.slug !== requestedRoute.slug) {
return false;
}
const createdAt = resolvePostCreatedAt(post);
return createdAt.getFullYear() === requestedRoute.year
&& (createdAt.getMonth() + 1) === requestedRoute.month
&& createdAt.getDate() === requestedRoute.day;
});
if (routePost) {
requestedPostIds.add(routePost.id);
for (const category of routePost.categories || []) {
requestedCategories.add(category);
}
for (const tag of routePost.tags || []) {
requestedTags.add(tag);
}
const createdAt = resolvePostCreatedAt(routePost);
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
requestedYears.add(year);
requestedYearMonths.add(`${year}/${month}`);
requestedYearMonthDays.add(`${year}/${month}/${day}`);
} else {
requestedYears.add(requestedRoute.year);
requestedYearMonths.add(`${requestedRoute.year}/${String(requestedRoute.month).padStart(2, '0')}`);
requestedYearMonthDays.add(`${requestedRoute.year}/${String(requestedRoute.month).padStart(2, '0')}/${String(requestedRoute.day).padStart(2, '0')}`);
}
}
for (const year of Array.from(requestedYears.values())) {
for (const ym of availableYearMonths) {
if (ym.startsWith(`${year}/`)) {
requestedYearMonths.add(ym);
}
}
}
for (const ym of Array.from(requestedYearMonths.values())) {
for (const ymd of availableYearMonthDays) {
if (ymd.startsWith(`${ym}/`)) {
requestedYearMonthDays.add(ymd);
}
}
const [yearStr] = ym.split('/');
requestedYears.add(Number(yearStr));
}
for (const ymd of Array.from(requestedYearMonthDays.values())) {
const [yearStr, monthStr] = ymd.split('/');
requestedYears.add(Number(yearStr));
requestedYearMonths.add(`${yearStr}/${monthStr}`);
}
return {
requestedPostIds,
requestedCategorySet: new Set(
Array.from(requestedCategories.values()).filter((category) => allCategories.has(category)),
),
requestedTagSet: new Set(
Array.from(requestedTags.values()).filter((tag) => allTags.has(tag)),
),
requestedYears,
requestedYearMonths,
requestedYearMonthDays,
requestedPageSlugs: new Set(initialPlan.requestedPageSlugs),
requestRootRoutes: initialPlan.requestRootRoutes,
};
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import type { PostData } from '../../src/main/engine/PostEngine';
import {
loadPublishedGenerationSets,
type GenerationSnapshotPostEngine,
} from '../../src/main/engine/GenerationPostSnapshotService';
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
const updatedAt = overrides.updatedAt ?? createdAt;
const title = overrides.title ?? 'Title';
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'default',
title,
slug: overrides.slug ?? 'title',
excerpt: overrides.excerpt,
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
};
}
function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData | null> = {}): GenerationSnapshotPostEngine {
return {
async getPublishedVersion(postId: string): Promise<PostData | null> {
return snapshotsById[postId] ?? null;
},
async getPostsFiltered(filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }): Promise<PostData[]> {
return posts
.filter((post) => {
if (filter.status && post.status !== filter.status) {
return false;
}
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
const categories = post.categories || [];
if (categories.some((category) => filter.excludeCategories?.includes(category))) {
return false;
}
}
return true;
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
},
};
}
describe('GenerationPostSnapshotService', () => {
it('loads published and list snapshots merged from published rows and draft published snapshots', async () => {
const published = makePost({ id: 'pub-1', status: 'published', categories: ['news'] });
const draft = makePost({ id: 'draft-1', status: 'draft', categories: ['news'] });
const draftSnapshot = makePost({ id: 'draft-1', status: 'published', categories: ['news'] });
const result = await loadPublishedGenerationSets(makeEngine([published, draft], { 'draft-1': draftSnapshot }), []);
expect(result.publishedPosts).toHaveLength(2);
expect(result.publishedListPosts).toHaveLength(2);
expect(result.publishedPosts.map((post) => post.id).sort()).toEqual(['draft-1', 'pub-1']);
});
it('excludes list-disabled categories only from list snapshot set', async () => {
const article = makePost({ id: 'article', status: 'published', categories: ['article'] });
const page = makePost({ id: 'page', status: 'published', categories: ['page'] });
const result = await loadPublishedGenerationSets(makeEngine([article, page]), ['page']);
expect(result.publishedPosts.map((post) => post.id).sort()).toEqual(['article', 'page']);
expect(result.publishedListPosts.map((post) => post.id)).toEqual(['article']);
});
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import type { PostData } from '../../src/main/engine/PostEngine';
import {
buildSitemapAndFeeds,
type GenerationPostIndexLike,
} from '../../src/main/engine/GenerationSitemapFeedService';
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
const updatedAt = overrides.updatedAt ?? createdAt;
const title = overrides.title ?? 'Title';
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'default',
title,
slug: overrides.slug ?? 'title',
excerpt: overrides.excerpt,
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
};
}
function buildIndex(posts: PostData[]): GenerationPostIndexLike {
const postsByCategory = new Map<string, PostData[]>();
const postsByTag = new Map<string, PostData[]>();
const postsByYear = new Map<number, PostData[]>();
const postsByYearMonth = new Map<string, PostData[]>();
const postsByYearMonthDay = new Map<string, PostData[]>();
for (const post of posts) {
const categories = Array.isArray(post.categories) ? post.categories : [];
for (const category of categories) {
const existing = postsByCategory.get(category) ?? [];
existing.push(post);
postsByCategory.set(category, existing);
}
const tags = Array.isArray(post.tags) ? post.tags : [];
for (const tag of tags) {
const existing = postsByTag.get(tag) ?? [];
existing.push(post);
postsByTag.set(tag, existing);
}
const createdAt = post.createdAt;
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
const yearMonth = `${year}/${month}`;
const yearMonthDay = `${year}/${month}/${day}`;
postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]);
postsByYearMonth.set(yearMonth, [...(postsByYearMonth.get(yearMonth) ?? []), post]);
postsByYearMonthDay.set(yearMonthDay, [...(postsByYearMonthDay.get(yearMonthDay) ?? []), post]);
}
return {
postsByCategory,
postsByTag,
postsByYear,
postsByYearMonth,
postsByYearMonthDay,
};
}
describe('GenerationSitemapFeedService', () => {
it('builds canonical sitemap urls and paginated archive routes', () => {
const publishedPosts = [
makePost({
id: '1',
slug: 'news-1',
createdAt: new Date('2025-01-15T10:00:00.000Z'),
categories: ['news'],
tags: ['tag-a'],
}),
makePost({
id: '2',
slug: 'news-2',
createdAt: new Date('2025-01-14T10:00:00.000Z'),
categories: ['news'],
tags: ['tag-a'],
}),
makePost({
id: '3',
slug: 'about',
createdAt: new Date('2025-01-13T10:00:00.000Z'),
categories: ['page'],
}),
];
const publishedListPosts = publishedPosts.filter((post) => !(post.categories || []).includes('page'));
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
projectDescription: 'Desc',
maxPostsPerPage: 1,
publishedPosts,
publishedListPosts,
postIndex: buildIndex(publishedListPosts),
includeFeeds: true,
});
expect(result.sitemapXml).toContain('<loc>https://example.com/</loc>');
expect(result.sitemapXml).toContain('<loc>https://example.com/page/2/</loc>');
expect(result.sitemapXml).toContain('<loc>https://example.com/2025/01/15/news-1/</loc>');
expect(result.sitemapXml).toContain('<loc>https://example.com/category/news/page/2/</loc>');
expect(result.sitemapXml).toContain('<loc>https://example.com/tag/tag-a/page/2/</loc>');
expect(result.sitemapXml).toContain('<loc>https://example.com/about/</loc>');
expect(result.rssXml).toContain('<rss version="2.0"');
expect(result.atomXml).toContain('<feed xmlns="http://www.w3.org/2005/Atom">');
});
it('can skip feed xml generation for sitemap-only flows', () => {
const publishedPosts = [makePost({ id: '1', slug: 'post-1', categories: ['news'], tags: ['t1'] })];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: false,
});
expect(result.sitemapXml).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
expect(result.rssXml).toBe('');
expect(result.atomXml).toBe('');
});
});

View File

@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import type { PostData, PostFilter } from '../../src/main/engine/PostEngine';
import {
findSinglePostBySlug,
loadPostsForDayPage,
loadPublishedSnapshotsPage,
type SharedSnapshotPostEngine,
} from '../../src/main/engine/SharedSnapshotService';
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
const updatedAt = overrides.updatedAt ?? createdAt;
const title = overrides.title ?? 'Title';
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'default',
title,
slug: overrides.slug ?? 'title',
excerpt: overrides.excerpt,
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
};
}
function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData | null> = {}): SharedSnapshotPostEngine {
const byId = new Map(posts.map((post) => [post.id, post]));
return {
async getPost(id: string): Promise<PostData | null> {
return byId.get(id) ?? null;
},
async getPublishedVersion(id: string): Promise<PostData | null> {
return snapshotsById[id] ?? null;
},
async getPostsFiltered(filter: PostFilter): Promise<PostData[]> {
let result = posts.filter((post) => post.status === (filter.status ?? post.status));
if (filter.tags && filter.tags.length > 0) {
result = result.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag)));
}
if (filter.categories && filter.categories.length > 0) {
result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
}
if ((filter as any).excludeCategories && (filter as any).excludeCategories.length > 0) {
result = result.filter((post) => !(filter as any).excludeCategories.some((category: string) => post.categories.includes(category)));
}
if (filter.year !== undefined) {
result = result.filter((post) => post.createdAt.getFullYear() === filter.year);
}
if (filter.month !== undefined && filter.year !== undefined) {
result = result.filter((post) => post.createdAt.getMonth() === filter.month);
}
if (filter.startDate) {
result = result.filter((post) => post.createdAt >= filter.startDate!);
}
if (filter.endDate) {
result = result.filter((post) => post.createdAt <= filter.endDate!);
}
return result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
},
};
}
describe('SharedSnapshotService', () => {
it('loads published snapshots merged from published and draft rows', async () => {
const published = makePost({ id: 'p1', slug: 'published-1', status: 'published' });
const draft = makePost({ id: 'd1', slug: 'draft-1', status: 'draft' });
const draftPublishedSnapshot = makePost({ id: 'd1', slug: 'draft-1', status: 'published' });
const engine = makeEngine([published, draft], { d1: draftPublishedSnapshot });
const result = await loadPublishedSnapshotsPage(engine, { status: 'published' }, { maxPostsPerPage: 50, page: 1 });
expect(result.totalPosts).toBe(2);
expect(result.posts.map((post) => post.id).sort()).toEqual(['d1', 'p1']);
});
it('loads day page strictly for given day', async () => {
const dayA = makePost({ id: 'a', slug: 'a', createdAt: new Date('2025-01-15T10:00:00.000Z') });
const dayB = makePost({ id: 'b', slug: 'b', createdAt: new Date('2025-01-16T10:00:00.000Z') });
const engine = makeEngine([dayA, dayB]);
const result = await loadPostsForDayPage(engine, 2025, 1, 15, { maxPostsPerPage: 50, page: 1 });
expect(result.totalPosts).toBe(1);
expect(result.posts).toHaveLength(1);
expect(result.posts[0]?.id).toBe('a');
});
it('prefers matching draft post when draft preview options are provided', async () => {
const draft = makePost({ id: 'draft-1', slug: 'my-post', status: 'draft', createdAt: new Date('2025-03-21T10:00:00.000Z') });
const published = makePost({ id: 'pub-1', slug: 'my-post', status: 'published', createdAt: new Date('2025-03-20T10:00:00.000Z') });
const engine = makeEngine([published, draft]);
const result = await findSinglePostBySlug(
engine,
'my-post',
{ useDraftContent: true, draftPostId: 'draft-1' },
{ year: 2025, month: 2, day: 21 },
);
expect(result?.id).toBe('draft-1');
});
it('uses findPublishedBySlug shortcut when present', async () => {
const post = makePost({ id: 'x1', slug: 'shortcut', status: 'published' });
const engine = makeEngine([post]);
const findPublishedBySlug = vi.fn(async () => post);
const engineWithShortcut: SharedSnapshotPostEngine = {
...engine,
findPublishedBySlug,
};
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 0, day: 2 });
expect(result?.id).toBe('x1');
expect(findPublishedBySlug).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,66 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { compareSitemapToHtml } from '../../src/main/engine/SiteValidationDiffService';
function makeTempName(): string {
return `bds-site-validation-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
describe('SiteValidationDiffService', () => {
it('computes missing and extra URL paths from sitemap xml and html tree', async () => {
const tempRoot = path.join('/tmp', makeTempName());
const htmlDir = path.join(tempRoot, 'html');
await mkdir(path.join(htmlDir, 'category', 'news', 'page', '2'), { recursive: true });
await mkdir(path.join(htmlDir, 'stale'), { recursive: true });
await writeFile(path.join(htmlDir, 'index.html'), '<html>root</html>', 'utf-8');
await writeFile(path.join(htmlDir, 'category', 'news', 'index.html'), '<html>news</html>', 'utf-8');
await writeFile(path.join(htmlDir, 'category', 'news', 'page', '2', 'index.html'), '<html>news p2</html>', 'utf-8');
await writeFile(path.join(htmlDir, 'stale', 'index.html'), '<html>stale</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/</loc></url>',
' <url><loc>https://example.com/category/news/</loc></url>',
' <url><loc>https://example.com/category/news/page/2/</loc></url>',
' <url><loc>https://example.com/tag/dev/</loc></url>',
'</urlset>',
'',
].join('\n');
const result = await compareSitemapToHtml({
sitemapXml,
baseUrl: 'https://example.com',
htmlDir,
});
expect(result.missingUrlPaths).toEqual(['/tag/dev']);
expect(result.extraUrlPaths).toEqual(['/stale']);
expect(result.expectedUrlCount).toBe(4);
expect(result.existingHtmlUrlCount).toBe(4);
});
it('normalizes base path urls and tolerates missing html dir', async () => {
const sitemapXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
' <url><loc>https://example.com/blog/</loc></url>',
' <url><loc>https://example.com/blog/page/2/</loc></url>',
'</urlset>',
'',
].join('\n');
const result = await compareSitemapToHtml({
sitemapXml,
baseUrl: 'https://example.com/blog',
htmlDir: path.join('/tmp', makeTempName(), 'missing-html-dir'),
});
expect(result.missingUrlPaths).toEqual(['/', '/page/2']);
expect(result.extraUrlPaths).toEqual([]);
expect(result.expectedUrlCount).toBe(2);
expect(result.existingHtmlUrlCount).toBe(0);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import type { PostData } from '../../src/main/engine/PostEngine';
import {
buildTargetedValidationPlan,
planMissingValidationPaths,
} from '../../src/main/engine/ValidationApplyPlannerService';
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-01-15T10:00:00.000Z');
const updatedAt = overrides.updatedAt ?? createdAt;
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'default',
title: overrides.title ?? 'Title',
slug: overrides.slug ?? 'title',
excerpt: overrides.excerpt,
content: overrides.content ?? 'Body',
status: overrides.status ?? 'published',
author: overrides.author,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
};
}
describe('ValidationApplyPlannerService', () => {
it('classifies missing paths into route request groups', () => {
const plan = planMissingValidationPaths([
'/',
'/page/2',
'/category/news/page/2',
'/tag/dev%20log',
'/2025/01/15/my%20post',
'/2025/page/2',
'/2025/01',
'/2025/01/15',
'/about',
]);
expect(plan.requiresFallbackSectionRender).toBe(false);
expect(plan.requestRootRoutes).toBe(true);
expect(Array.from(plan.requestedCategories)).toEqual(['news']);
expect(Array.from(plan.requestedTags)).toEqual(['dev log']);
expect(plan.requestedPostRoutes).toEqual([
{ year: 2025, month: 1, day: 15, slug: 'my post' },
]);
expect(Array.from(plan.requestedYears)).toContain(2025);
expect(Array.from(plan.requestedYearMonths)).toContain('2025/01');
expect(Array.from(plan.requestedYearMonthDays)).toContain('2025/01/15');
expect(Array.from(plan.requestedPageSlugs)).toEqual(['about']);
});
it('expands targeted rerender plan with single-route lineage and available archives', () => {
const publishedPost = makePost({
id: 'p1',
slug: 'post-one',
categories: ['news'],
tags: ['tag-1'],
createdAt: new Date('2025-01-15T10:00:00.000Z'),
});
const pagePost = makePost({
id: 'p2',
slug: 'about',
categories: ['page'],
tags: [],
createdAt: new Date('2025-01-10T10:00:00.000Z'),
});
const initialPlan = planMissingValidationPaths(['/2025/01/15/post-one', '/2025', '/about', '/category/missing']);
const targeted = buildTargetedValidationPlan({
initialPlan,
publishedPosts: [publishedPost, pagePost],
allCategories: new Set(['news', 'page']),
allTags: new Set(['tag-1']),
availableYearMonths: ['2025/01', '2025/02'],
availableYearMonthDays: ['2025/01/15', '2025/02/20'],
});
expect(targeted.requestedPostIds.has('p1')).toBe(true);
expect(targeted.requestedCategorySet.has('news')).toBe(true);
expect(targeted.requestedCategorySet.has('missing')).toBe(false);
expect(targeted.requestedTagSet.has('tag-1')).toBe(true);
expect(targeted.requestedYears.has(2025)).toBe(true);
expect(targeted.requestedYearMonths.has('2025/01')).toBe(true);
expect(targeted.requestedYearMonths.has('2025/02')).toBe(true);
expect(targeted.requestedYearMonthDays.has('2025/01/15')).toBe(true);
expect(targeted.requestedYearMonthDays.has('2025/02/20')).toBe(true);
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
});
});