chore: refactorings and code sharing
This commit is contained in:
File diff suppressed because it is too large
Load Diff
73
src/main/engine/GenerationPostSnapshotService.ts
Normal file
73
src/main/engine/GenerationPostSnapshotService.ts
Normal 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()),
|
||||||
|
};
|
||||||
|
}
|
||||||
375
src/main/engine/GenerationSitemapFeedService.ts
Normal file
375
src/main/engine/GenerationSitemapFeedService.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
190
src/main/engine/SharedSnapshotService.ts
Normal file
190
src/main/engine/SharedSnapshotService.ts
Normal 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);
|
||||||
|
}
|
||||||
114
src/main/engine/SiteValidationDiffService.ts
Normal file
114
src/main/engine/SiteValidationDiffService.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
243
src/main/engine/ValidationApplyPlannerService.ts
Normal file
243
src/main/engine/ValidationApplyPlannerService.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
78
tests/engine/GenerationPostSnapshotService.test.ts
Normal file
78
tests/engine/GenerationPostSnapshotService.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
tests/engine/GenerationSitemapFeedService.test.ts
Normal file
138
tests/engine/GenerationSitemapFeedService.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
133
tests/engine/SharedSnapshotService.test.ts
Normal file
133
tests/engine/SharedSnapshotService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/engine/SiteValidationDiffService.test.ts
Normal file
66
tests/engine/SiteValidationDiffService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
tests/engine/ValidationApplyPlannerService.test.ts
Normal file
94
tests/engine/ValidationApplyPlannerService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user