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,
buildCanonicalPostPath,
clampMaxPostsPerPage,
parseRoutePagination,
resolvePageTitle,
type CategoryRenderSettings,
type HtmlRewriteContext,
@@ -23,6 +22,12 @@ import {
} from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
import {
findSinglePostBySlug,
loadPostsForDayPage,
loadPublishedSnapshots,
loadPublishedSnapshotsPage,
} from './SharedSnapshotService';
interface ActiveProjectContext {
projectId: string;
@@ -179,10 +184,10 @@ export class PreviewServer {
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
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),
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
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(
rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string> {
const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, {
const result = await loadPublishedSnapshotsPage(this.postEngine, { status: 'published', excludeCategories: listExcludedCategories }, {
maxPostsPerPage: 10,
page: 1,
});
@@ -507,190 +335,8 @@ export class PreviewServer {
}, 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> {
const publishedPosts = await this.loadPublishedSnapshots({ status: 'published' });
const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' });
const canonicalPostPathBySlug = new Map<string, string>();
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,
};
}