From 2a73db57b44ca6e259d5d35763c3077e8e79e522 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 09:20:22 +0100 Subject: [PATCH] chore: refactorings and code sharing --- src/main/engine/BlogGenerationEngine.ts | 863 ++---------------- .../engine/GenerationPostSnapshotService.ts | 73 ++ .../engine/GenerationSitemapFeedService.ts | 375 ++++++++ src/main/engine/PreviewServer.ts | 378 +------- src/main/engine/SharedSnapshotService.ts | 190 ++++ src/main/engine/SiteValidationDiffService.ts | 114 +++ .../engine/ValidationApplyPlannerService.ts | 243 +++++ .../GenerationPostSnapshotService.test.ts | 78 ++ .../GenerationSitemapFeedService.test.ts | 138 +++ tests/engine/SharedSnapshotService.test.ts | 133 +++ .../engine/SiteValidationDiffService.test.ts | 66 ++ .../ValidationApplyPlannerService.test.ts | 94 ++ 12 files changed, 1587 insertions(+), 1158 deletions(-) create mode 100644 src/main/engine/GenerationPostSnapshotService.ts create mode 100644 src/main/engine/GenerationSitemapFeedService.ts create mode 100644 src/main/engine/SharedSnapshotService.ts create mode 100644 src/main/engine/SiteValidationDiffService.ts create mode 100644 src/main/engine/ValidationApplyPlannerService.ts create mode 100644 tests/engine/GenerationPostSnapshotService.test.ts create mode 100644 tests/engine/GenerationSitemapFeedService.test.ts create mode 100644 tests/engine/SharedSnapshotService.test.ts create mode 100644 tests/engine/SiteValidationDiffService.test.ts create mode 100644 tests/engine/ValidationApplyPlannerService.test.ts diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 92a42a4..8dcd21f 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -20,6 +20,10 @@ import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '.. import type { MenuDocument } from './MenuEngine'; import type { ProjectMetadata } from './MetaEngine'; import { PreviewServer } from './PreviewServer'; +import { loadPublishedGenerationSets } from './GenerationPostSnapshotService'; +import { buildSitemapAndFeeds, type GenerationPostIndexLike } from './GenerationSitemapFeedService'; +import { buildTargetedValidationPlan, planMissingValidationPaths } from './ValidationApplyPlannerService'; +import { compareSitemapToHtml } from './SiteValidationDiffService'; const DEFAULT_MAX_POSTS_PER_PAGE = 50; const MIN_MAX_POSTS_PER_PAGE = 1; @@ -82,13 +86,7 @@ export interface SiteValidationApplyResult { removedEmptyDirCount: number; } -interface GenerationPostIndex { - postsByCategory: Map; - postsByTag: Map; - postsByYear: Map; - postsByYearMonth: Map; - postsByYearMonthDay: Map; -} +type GenerationPostIndex = GenerationPostIndexLike; export function resolvePublicBaseUrl(publicUrl?: string): string | null { const trimmed = (publicUrl || '').trim(); @@ -162,13 +160,6 @@ function resolveCategoryDisplayTitle( return trimmed.length > 0 ? trimmed : category; } -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 resolvePostCreatedAt(post: { createdAt: Date | string }): Date { if (post.createdAt instanceof Date) { return post.createdAt; @@ -178,43 +169,6 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date { return Number.isNaN(parsed.getTime()) ? new Date() : parsed; } -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, '''); -} - -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 [ - ' ', - ` ${escapeXml(canonicalLoc)}`, - ` ${escapeXml(lastmod)}`, - ` ${changefreq}`, - ` ${priority}`, - ' ', - ].join('\n'); -} function normalizeUrlPath(urlPath: string): string { const trimmed = (urlPath || '').trim(); @@ -236,91 +190,6 @@ function urlPathToHtmlIndexPath(htmlDir: string, urlPath: string): string { return path.join(htmlDir, normalizedPath.slice(1), 'index.html'); } -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>/g); - const locs: string[] = []; - for (const match of matches) { - const value = match[1]?.trim(); - if (value) { - locs.push(value); - } - } - return locs; -} - -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, '
'); - return `

${escaped}

`; -} - -function markdownToXhtml(markdown: string): string { - const paragraphs = splitParagraphs(markdown); - if (paragraphs.length === 0) { - return '

'; - } - 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, ']]]]>'); -} function computeContentHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); @@ -372,258 +241,37 @@ export class BlogGenerationEngine { .map(([category]) => category); const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage); - const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' }); - const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' }); - const publishedListCandidates = await this.postEngine.getPostsFiltered({ - status: 'published', - excludeCategories: listExcludedCategories, - }); - const draftListCandidates = await this.postEngine.getPostsFiltered({ - status: 'draft', - excludeCategories: listExcludedCategories, - }); - - const publishedSnapshots = await Promise.all( - publishedCandidates.map(async (post) => { - const snapshot = await this.postEngine.getPublishedVersion(post.id); - return snapshot || post; - }), - ); - const draftPublishedSnapshots = await Promise.all( - draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), - ); - const publishedListSnapshots = await Promise.all( - publishedListCandidates.map(async (post) => { - const snapshot = await this.postEngine.getPublishedVersion(post.id); - return snapshot || post; - }), - ); - const draftListPublishedSnapshots = await Promise.all( - draftListCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), - ); - - const publishedPostById = new Map(); - for (const post of publishedSnapshots) { - publishedPostById.set(post.id, post); - } - for (const snapshot of draftPublishedSnapshots) { - if (snapshot) { - publishedPostById.set(snapshot.id, snapshot); - } - } - - const publishedPosts = Array.from(publishedPostById.values()) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - const publishedListPostById = new Map(); - for (const post of publishedListSnapshots) { - publishedListPostById.set(post.id, post); - } - for (const snapshot of draftListPublishedSnapshots) { - if (snapshot) { - publishedListPostById.set(snapshot.id, snapshot); - } - } - const publishedListPosts = Array.from(publishedListPostById.values()) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - const feedPosts = publishedListPosts.slice(0, maxPostsPerPage); + const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories); onProgress(3, `Found ${publishedPosts.length} published posts`); - const now = new Date().toISOString(); - const allTags = new Set(); - const allCategories = new Set(); - const yearMonths = new Map(); - const years = new Map(); - const yearMonthDays = new Map(); - 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 = `${options.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: `${options.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 generationPostIndex = this.buildGenerationPostIndex(publishedListPosts); onProgress(5, 'Building sitemap XML...'); - - const urls: string[] = []; - urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0')); - appendPaginatedSitemapUrls(urls, options.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(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5')); - - const yearCount = generationPostIndex.postsByYear.get(year)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); - } - for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5')); - - const monthCount = generationPostIndex.postsByYearMonth.get(ym)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); - } - for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4')); - - const dayCount = generationPostIndex.postsByYearMonthDay.get(ymd)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3'); - } - - for (const category of Array.from(allCategories).sort()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6')); - - const categoryCount = generationPostIndex.postsByCategory.get(category)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); - } - - for (const tag of Array.from(allTags).sort()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6')); - - const tagCount = generationPostIndex.postsByTag.get(tag)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); - } + const { + allTags, + allCategories, + yearMonths, + years, + yearMonthDays, + urls, + sitemapXml, + rssXml, + atomXml, + feedPosts, + } = buildSitemapAndFeeds({ + baseUrl: options.baseUrl, + projectName: options.projectName, + projectDescription: options.projectDescription, + maxPostsPerPage, + publishedPosts, + publishedListPosts, + postIndex: generationPostIndex, + includeFeeds: true, + }); onProgress(8, 'Building RSS and Atom feeds...'); - const sitemapXml = [ - '', - '', - ...urls, - '', - '', - ].join('\n'); - - const feedUpdatedAt = feedPosts[0]?.updatedAt || new Date(); - const baseLink = `${options.baseUrl}/`; - const feedTitle = options.projectName; - const feedDescription = options.projectDescription?.trim() || feedTitle; - - const rssItems = feedPosts.map((post) => { - const createdAt = resolvePostCreatedAt(post); - const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); - const permalink = `${options.baseUrl}${canonicalPath}`; - const excerptXhtml = excerptToXhtml(post); - const contentXhtml = markdownToXhtml(post.content || ''); - const categories = [ - ...(post.categories || []).map((category) => `${escapeXml(category)}`), - ...(post.tags || []).map((tag) => `${escapeXml(tag)}`), - ]; - - return [ - ' ', - ` ${escapeXml(post.title)}`, - ` ${escapeXml(permalink)}`, - ` ${escapeXml(permalink)}`, - ` ${(post.publishedAt || post.updatedAt).toUTCString()}`, - post.author ? ` ${escapeXml(post.author)}` : null, - ` `, - ` `, - ...categories.map((entry) => ` ${entry}`), - ' ', - ].filter(Boolean).join('\n'); - }); - - const rssXml = [ - '', - '', - ' ', - ` ${escapeXml(feedTitle)}`, - ` ${escapeXml(baseLink)}`, - ` ${escapeXml(feedDescription)}`, - ` ${feedUpdatedAt.toUTCString()}`, - ' bDS', - ...rssItems, - ' ', - '', - '', - ].join('\n'); - - const atomEntries = feedPosts.map((post) => { - const createdAt = resolvePostCreatedAt(post); - const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); - const permalink = `${options.baseUrl}${canonicalPath}`; - const excerptXhtml = excerptToXhtml(post); - const contentXhtml = markdownToXhtml(post.content || ''); - const categories = [ - ...(post.tags || []).map((tag) => ``), - ...(post.categories || []).map((category) => ``), - ]; - - return [ - ' ', - ` ${escapeXml(post.title)}`, - ` ${escapeXml(permalink)}`, - ` `, - ` ${post.updatedAt.toISOString()}`, - ` ${(post.publishedAt || post.updatedAt).toISOString()}`, - post.author ? ` ${escapeXml(post.author)}` : null, - `
${excerptXhtml}
`, - `
${contentXhtml}
`, - ...categories.map((entry) => ` ${entry}`), - '
', - ].filter(Boolean).join('\n'); - }); - - const atomXml = [ - '', - '', - ` ${escapeXml(feedTitle)}`, - ` ${escapeXml(feedDescription)}`, - ` ${escapeXml(baseLink)}`, - ` `, - ` `, - ` ${feedUpdatedAt.toISOString()}`, - ...atomEntries, - '', - '', - ].join('\n'); - const htmlDir = path.join(options.dataDir, 'html'); await fs.mkdir(htmlDir, { recursive: true }); const sitemapPath = path.join(htmlDir, 'sitemap.xml'); @@ -724,7 +372,7 @@ export class BlogGenerationEngine { return { path: sitemapPath, urlCount: urls.length, - postCount: postUrls.length, + postCount: publishedPosts.length, feedPostCount: feedPosts.length, tagCount: allTags.size, categoryCount: allCategories.size, @@ -754,166 +402,19 @@ export class BlogGenerationEngine { .filter(([, settings]) => settings.renderInLists === false) .map(([category]) => category); - const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' }); - const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' }); - const publishedListCandidates = await this.postEngine.getPostsFiltered({ - status: 'published', - excludeCategories: listExcludedCategories, - }); - const draftListCandidates = await this.postEngine.getPostsFiltered({ - status: 'draft', - excludeCategories: listExcludedCategories, - }); - - const publishedSnapshots = await Promise.all( - publishedCandidates.map(async (post) => { - const snapshot = await this.postEngine.getPublishedVersion(post.id); - return snapshot || post; - }), - ); - const draftPublishedSnapshots = await Promise.all( - draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), - ); - const publishedListSnapshots = await Promise.all( - publishedListCandidates.map(async (post) => { - const snapshot = await this.postEngine.getPublishedVersion(post.id); - return snapshot || post; - }), - ); - const draftListPublishedSnapshots = await Promise.all( - draftListCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), - ); - - const publishedPostById = new Map(); - for (const post of publishedSnapshots) { - publishedPostById.set(post.id, post); - } - for (const snapshot of draftPublishedSnapshots) { - if (snapshot) { - publishedPostById.set(snapshot.id, snapshot); - } - } - - const publishedPosts = Array.from(publishedPostById.values()) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - const publishedListPostById = new Map(); - for (const post of publishedListSnapshots) { - publishedListPostById.set(post.id, post); - } - for (const snapshot of draftListPublishedSnapshots) { - if (snapshot) { - publishedListPostById.set(snapshot.id, snapshot); - } - } - const publishedListPosts = Array.from(publishedListPostById.values()) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories); const generationPostIndex = this.buildGenerationPostIndex(publishedListPosts); - const now = new Date().toISOString(); - const allTags = new Set(); - const allCategories = new Set(); - const yearMonths = new Map(); - const years = new Map(); - const yearMonthDays = new Map(); - 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 = `${options.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: `${options.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(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0')); - appendPaginatedSitemapUrls(urls, options.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(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5')); - - const yearCount = generationPostIndex.postsByYear.get(year)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); - } - for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5')); - - const monthCount = generationPostIndex.postsByYearMonth.get(ym)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4'); - } - for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4')); - - const dayCount = generationPostIndex.postsByYearMonthDay.get(ymd)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3'); - } - - for (const category of Array.from(allCategories).sort()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6')); - - const categoryCount = generationPostIndex.postsByCategory.get(category)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); - } - - for (const tag of Array.from(allTags).sort()) { - urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6')); - - const tagCount = generationPostIndex.postsByTag.get(tag)?.length ?? 0; - appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5'); - } - - const sitemapXml = [ - '', - '', - ...urls, - '', - '', - ].join('\n'); + const { sitemapXml } = buildSitemapAndFeeds({ + baseUrl: options.baseUrl, + projectName: options.projectName, + projectDescription: options.projectDescription, + maxPostsPerPage, + publishedPosts, + publishedListPosts, + postIndex: generationPostIndex, + includeFeeds: false, + }); const htmlDir = path.join(options.dataDir, 'html'); await fs.mkdir(htmlDir, { recursive: true }); @@ -922,58 +423,21 @@ export class BlogGenerationEngine { onProgress(50, 'Comparing sitemap to html pages...'); - const expectedPathSet = new Set( - extractSitemapLocs(sitemapXml) - .map((loc) => sitemapLocToProjectPath(loc, options.baseUrl)) - .map((value) => normalizeUrlPath(value)), - ); + const diffResult = await compareSitemapToHtml({ + sitemapXml, + baseUrl: options.baseUrl, + htmlDir, + }); - const existingHtmlPathSet = new Set(); - const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise => { - 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); - - const missingUrlPaths = Array.from(expectedPathSet) - .filter((value) => !existingHtmlPathSet.has(value)) - .sort(); - - const extraUrlPaths = Array.from(existingHtmlPathSet) - .filter((value) => !expectedPathSet.has(value)) - .sort(); - - onProgress(100, `Validation complete (${missingUrlPaths.length} missing, ${extraUrlPaths.length} extra)`); + onProgress(100, `Validation complete (${diffResult.missingUrlPaths.length} missing, ${diffResult.extraUrlPaths.length} extra)`); return { sitemapPath, sitemapChanged, - missingUrlPaths, - extraUrlPaths, - expectedUrlCount: expectedPathSet.size, - existingHtmlUrlCount: existingHtmlPathSet.size, + missingUrlPaths: diffResult.missingUrlPaths, + extraUrlPaths: diffResult.extraUrlPaths, + expectedUrlCount: diffResult.expectedUrlCount, + existingHtmlUrlCount: diffResult.existingHtmlUrlCount, }; } @@ -989,82 +453,7 @@ export class BlogGenerationEngine { onProgress(10, 'Planning validation apply steps...'); - const requestedCategories = new Set(); - const requestedTags = new Set(); - const requestedYears = new Set(); - const requestedYearMonths = new Set(); - const requestedYearMonthDays = new Set(); - const requestedPostRoutes: Array<{ year: number; month: number; day: number; slug: string }> = []; - const requestedPageSlugs = new Set(); - let requestRootRoutes = false; - let requiresFallbackSectionRender = false; - - const decodePathSegment = (value: string): string => { - try { - return decodeURIComponent(value); - } catch { - return value; - } - }; - - 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; - } + const missingPathPlan = planMissingValidationPaths(missingPaths); onProgress(20, 'Deleting extra URLs...'); @@ -1112,7 +501,7 @@ export class BlogGenerationEngine { let renderedUrlCount = 0; - if (requiresFallbackSectionRender) { + if (missingPathPlan.requiresFallbackSectionRender) { onProgress(50, 'Rendering missing routes (fallback section mode)...'); const sectionExecutionOrder: BlogGenerationSection[] = ['category', 'tag', 'date', 'core', 'single']; for (let index = 0; index < sectionExecutionOrder.length; index += 1) { @@ -1137,59 +526,7 @@ export class BlogGenerationEngine { .map(([category]) => category); const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage); - const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' }); - const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' }); - const publishedListCandidates = await this.postEngine.getPostsFiltered({ - status: 'published', - excludeCategories: listExcludedCategories, - }); - const draftListCandidates = await this.postEngine.getPostsFiltered({ - status: 'draft', - excludeCategories: listExcludedCategories, - }); - - const publishedSnapshots = await Promise.all( - publishedCandidates.map(async (post) => { - const snapshot = await this.postEngine.getPublishedVersion(post.id); - return snapshot || post; - }), - ); - const draftPublishedSnapshots = await Promise.all( - draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), - ); - const publishedListSnapshots = await Promise.all( - publishedListCandidates.map(async (post) => { - const snapshot = await this.postEngine.getPublishedVersion(post.id); - return snapshot || post; - }), - ); - const draftListPublishedSnapshots = await Promise.all( - draftListCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), - ); - - const publishedPostById = new Map(); - for (const post of publishedSnapshots) { - publishedPostById.set(post.id, post); - } - for (const snapshot of draftPublishedSnapshots) { - if (snapshot) { - publishedPostById.set(snapshot.id, snapshot); - } - } - const publishedPosts = Array.from(publishedPostById.values()) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - const publishedListPostById = new Map(); - for (const post of publishedListSnapshots) { - publishedListPostById.set(post.id, post); - } - for (const snapshot of draftListPublishedSnapshots) { - if (snapshot) { - publishedListPostById.set(snapshot.id, snapshot); - } - } - const publishedListPosts = Array.from(publishedListPostById.values()) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories); const generationPostIndex = this.buildGenerationPostIndex(publishedListPosts); const allCategories = new Set(); @@ -1221,65 +558,14 @@ export class BlogGenerationEngine { } } - const requestedPostIds = new Set(); - for (const requestedRoute of 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 yearMonths.keys()) { - if (ym.startsWith(`${year}/`)) { - requestedYearMonths.add(ym); - } - } - } - - for (const ym of Array.from(requestedYearMonths.values())) { - for (const ymd of yearMonthDays.keys()) { - 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}`); - } + const targetedPlan = buildTargetedValidationPlan({ + initialPlan: missingPathPlan, + publishedPosts, + allCategories, + allTags, + availableYearMonths: yearMonths.keys(), + availableYearMonthDays: yearMonthDays.keys(), + }); const htmlDir = path.join(options.dataDir, 'html'); await fs.mkdir(htmlDir, { recursive: true }); @@ -1289,16 +575,9 @@ export class BlogGenerationEngine { // no-op for applyValidation }; - const requestedCategorySet = new Set( - Array.from(requestedCategories.values()).filter((category) => allCategories.has(category)), - ); - const requestedTagSet = new Set( - Array.from(requestedTags.values()).filter((tag) => allTags.has(tag)), - ); - - const requestedSinglePosts = publishedPosts.filter((post) => requestedPostIds.has(post.id)); + const requestedSinglePosts = publishedPosts.filter((post) => targetedPlan.requestedPostIds.has(post.id)); const requestedPagePosts = publishedPosts.filter((post) => { - if (!requestedPageSlugs.has(post.slug)) { + if (!targetedPlan.requestedPageSlugs.has(post.slug)) { return false; } const categories = Array.isArray(post.categories) ? post.categories : []; @@ -1306,7 +585,7 @@ export class BlogGenerationEngine { }); const requestedYearsMap = new Map(); - for (const year of requestedYears) { + for (const year of targetedPlan.requestedYears) { const lastmod = years.get(year); if (lastmod) { requestedYearsMap.set(year, lastmod); @@ -1314,7 +593,7 @@ export class BlogGenerationEngine { } const requestedYearMonthsMap = new Map(); - for (const ym of requestedYearMonths) { + for (const ym of targetedPlan.requestedYearMonths) { const lastmod = yearMonths.get(ym); if (lastmod) { requestedYearMonthsMap.set(ym, lastmod); @@ -1322,7 +601,7 @@ export class BlogGenerationEngine { } const requestedYearMonthDaysMap = new Map(); - for (const ymd of requestedYearMonthDays) { + for (const ymd of targetedPlan.requestedYearMonthDays) { const lastmod = yearMonthDays.get(ymd); if (lastmod) { requestedYearMonthDaysMap.set(ymd, lastmod); @@ -1331,12 +610,12 @@ export class BlogGenerationEngine { onProgress( 48, - `Targeted rerender plan: singles=${requestedSinglePosts.length}, categories=${requestedCategorySet.size}, tags=${requestedTagSet.size}, years=${requestedYearsMap.size}, months=${requestedYearMonthsMap.size}, days=${requestedYearMonthDaysMap.size}, root=${requestRootRoutes ? 1 : 0}, pages=${requestedPagePosts.length}`, + `Targeted rerender plan: singles=${requestedSinglePosts.length}, categories=${targetedPlan.requestedCategorySet.size}, tags=${targetedPlan.requestedTagSet.size}, years=${requestedYearsMap.size}, months=${requestedYearMonthsMap.size}, days=${requestedYearMonthDaysMap.size}, root=${targetedPlan.requestRootRoutes ? 1 : 0}, pages=${requestedPagePosts.length}`, ); onProgress(50, 'Rendering targeted missing routes...'); - if (requestRootRoutes) { + if (targetedPlan.requestRootRoutes) { renderedUrlCount += await this.generateRootPages( options.projectId, publishedListPosts, @@ -1357,11 +636,11 @@ export class BlogGenerationEngine { ); } - if (requestedCategorySet.size > 0) { + if (targetedPlan.requestedCategorySet.size > 0) { renderedUrlCount += await this.generateCategoryPages( options.projectId, publishedListPosts, - requestedCategorySet, + targetedPlan.requestedCategorySet, maxPostsPerPage, htmlDir, renderRoute, @@ -1370,11 +649,11 @@ export class BlogGenerationEngine { ); } - if (requestedTagSet.size > 0) { + if (targetedPlan.requestedTagSet.size > 0) { renderedUrlCount += await this.generateTagPages( options.projectId, publishedListPosts, - requestedTagSet, + targetedPlan.requestedTagSet, maxPostsPerPage, htmlDir, renderRoute, diff --git a/src/main/engine/GenerationPostSnapshotService.ts b/src/main/engine/GenerationPostSnapshotService.ts new file mode 100644 index 0000000..b3bd229 --- /dev/null +++ b/src/main/engine/GenerationPostSnapshotService.ts @@ -0,0 +1,73 @@ +import type { PostData } from './PostEngine'; + +export interface GenerationSnapshotPostEngine { + getPostsFiltered: (filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }) => Promise; + getPublishedVersion: (id: string) => Promise; +} + +export interface GenerationPublishedSets { + publishedPosts: PostData[]; + publishedListPosts: PostData[]; +} + +export async function loadPublishedGenerationSets( + postEngine: GenerationSnapshotPostEngine, + listExcludedCategories: string[], +): Promise { + 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(); + 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(); + 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()), + }; +} diff --git a/src/main/engine/GenerationSitemapFeedService.ts b/src/main/engine/GenerationSitemapFeedService.ts new file mode 100644 index 0000000..571a538 --- /dev/null +++ b/src/main/engine/GenerationSitemapFeedService.ts @@ -0,0 +1,375 @@ +import type { PostData } from './PostEngine'; + +export interface GenerationPostIndexLike { + postsByCategory: Map; + postsByTag: Map; + postsByYear: Map; + postsByYearMonth: Map; + postsByYearMonthDay: Map; +} + +interface BuildSitemapAndFeedsParams { + baseUrl: string; + projectName: string; + projectDescription?: string; + maxPostsPerPage: number; + publishedPosts: PostData[]; + publishedListPosts: PostData[]; + postIndex: GenerationPostIndexLike; + includeFeeds: boolean; +} + +export interface SitemapFeedBuildResult { + allTags: Set; + allCategories: Set; + yearMonths: Map; + years: Map; + yearMonthDays: Map; + 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, '''); +} + +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 [ + ' ', + ` ${escapeXml(canonicalLoc)}`, + ` ${escapeXml(lastmod)}`, + ` ${changefreq}`, + ` ${priority}`, + ' ', + ].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, '
'); + return `

${escaped}

`; +} + +function markdownToXhtml(markdown: string): string { + const paragraphs = splitParagraphs(markdown); + if (paragraphs.length === 0) { + return '

'; + } + 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, ']]]]>'); +} + +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(); + const allCategories = new Set(); + const yearMonths = new Map(); + const years = new Map(); + const yearMonthDays = new Map(); + 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 = [ + '', + '', + ...urls, + '', + '', + ].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) => `${escapeXml(category)}`), + ...(post.tags || []).map((tag) => `${escapeXml(tag)}`), + ]; + + return [ + ' ', + ` ${escapeXml(post.title)}`, + ` ${escapeXml(permalink)}`, + ` ${escapeXml(permalink)}`, + ` ${(post.publishedAt || post.updatedAt).toUTCString()}`, + post.author ? ` ${escapeXml(post.author)}` : null, + ` `, + ` `, + ...categories.map((entry) => ` ${entry}`), + ' ', + ].filter(Boolean).join('\n'); + }); + + const rssXml = [ + '', + '', + ' ', + ` ${escapeXml(feedTitle)}`, + ` ${escapeXml(baseLink)}`, + ` ${escapeXml(feedDescription)}`, + ` ${feedUpdatedAt.toUTCString()}`, + ' bDS', + ...rssItems, + ' ', + '', + '', + ].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) => ``), + ...(post.categories || []).map((category) => ``), + ]; + + return [ + ' ', + ` ${escapeXml(post.title)}`, + ` ${escapeXml(permalink)}`, + ` `, + ` ${post.updatedAt.toISOString()}`, + ` ${(post.publishedAt || post.updatedAt).toISOString()}`, + post.author ? ` ${escapeXml(post.author)}` : null, + `
${excerptXhtml}
`, + `
${contentXhtml}
`, + ...categories.map((entry) => ` ${entry}`), + '
', + ].filter(Boolean).join('\n'); + }); + + const atomXml = [ + '', + '', + ` ${escapeXml(feedTitle)}`, + ` ${escapeXml(feedDescription)}`, + ` ${escapeXml(baseLink)}`, + ` `, + ` `, + ` ${feedUpdatedAt.toISOString()}`, + ...atomEntries, + '', + '', + ].join('\n'); + + return { + allTags, + allCategories, + yearMonths, + years, + yearMonthDays, + urls, + sitemapXml, + rssXml, + atomXml, + feedPosts, + }; +} diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index fe7e582..93bd33e 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -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; picoStylesheetHref: string; htmlThemeAttribute?: string }, - categorySettings: Record, - categoryMetadata: Record, - listExcludedCategories: string[], - singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, - ): Promise { - 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; picoStylesheetHref: string; htmlThemeAttribute?: string }, categorySettings: Record, listExcludedCategories: string[], ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const publishedPosts = await this.loadPublishedSnapshots({ status: 'published' }); + const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' }); const canonicalPostPathBySlug = new Map(); for (const post of publishedPosts) { diff --git a/src/main/engine/SharedSnapshotService.ts b/src/main/engine/SharedSnapshotService.ts new file mode 100644 index 0000000..87400d8 --- /dev/null +++ b/src/main/engine/SharedSnapshotService.ts @@ -0,0 +1,190 @@ +import type { PostData, PostFilter } from './PostEngine'; + +export interface SharedSnapshotPostEngine { + getPostsFiltered: (filter: PostFilter) => Promise; + getPost: (id: string) => Promise; + getPublishedVersion: (id: string) => Promise; + findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise; +} + +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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/main/engine/SiteValidationDiffService.ts b/src/main/engine/SiteValidationDiffService.ts new file mode 100644 index 0000000..6e75fe2 --- /dev/null +++ b/src/main/engine/SiteValidationDiffService.ts @@ -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>/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> { + const existingHtmlPathSet = new Set(); + + const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise => { + 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 { + 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, + }; +} diff --git a/src/main/engine/ValidationApplyPlannerService.ts b/src/main/engine/ValidationApplyPlannerService.ts new file mode 100644 index 0000000..4aa9044 --- /dev/null +++ b/src/main/engine/ValidationApplyPlannerService.ts @@ -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; + requestedTags: Set; + requestedYears: Set; + requestedYearMonths: Set; + requestedYearMonthDays: Set; + requestedPostRoutes: RequestedPostRoute[]; + requestedPageSlugs: Set; + requestRootRoutes: boolean; + requiresFallbackSectionRender: boolean; +} + +export interface TargetedValidationPlan { + requestedPostIds: Set; + requestedCategorySet: Set; + requestedTagSet: Set; + requestedYears: Set; + requestedYearMonths: Set; + requestedYearMonthDays: Set; + requestedPageSlugs: Set; + 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(); + const requestedTags = new Set(); + const requestedYears = new Set(); + const requestedYearMonths = new Set(); + const requestedYearMonthDays = new Set(); + const requestedPostRoutes: RequestedPostRoute[] = []; + const requestedPageSlugs = new Set(); + 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; + allTags: Set; + availableYearMonths: Iterable; + availableYearMonthDays: Iterable; +} + +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(); + + 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, + }; +} diff --git a/tests/engine/GenerationPostSnapshotService.test.ts b/tests/engine/GenerationPostSnapshotService.test.ts new file mode 100644 index 0000000..d98c0bd --- /dev/null +++ b/tests/engine/GenerationPostSnapshotService.test.ts @@ -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 { + 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 = {}): GenerationSnapshotPostEngine { + return { + async getPublishedVersion(postId: string): Promise { + return snapshotsById[postId] ?? null; + }, + async getPostsFiltered(filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }): Promise { + 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']); + }); +}); diff --git a/tests/engine/GenerationSitemapFeedService.test.ts b/tests/engine/GenerationSitemapFeedService.test.ts new file mode 100644 index 0000000..d993041 --- /dev/null +++ b/tests/engine/GenerationSitemapFeedService.test.ts @@ -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 { + 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(); + const postsByTag = new Map(); + const postsByYear = new Map(); + const postsByYearMonth = new Map(); + const postsByYearMonthDay = new Map(); + + 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('https://example.com/'); + expect(result.sitemapXml).toContain('https://example.com/page/2/'); + expect(result.sitemapXml).toContain('https://example.com/2025/01/15/news-1/'); + expect(result.sitemapXml).toContain('https://example.com/category/news/page/2/'); + expect(result.sitemapXml).toContain('https://example.com/tag/tag-a/page/2/'); + expect(result.sitemapXml).toContain('https://example.com/about/'); + expect(result.rssXml).toContain(''); + }); + + 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(''); + expect(result.rssXml).toBe(''); + expect(result.atomXml).toBe(''); + }); +}); diff --git a/tests/engine/SharedSnapshotService.test.ts b/tests/engine/SharedSnapshotService.test.ts new file mode 100644 index 0000000..ee1288f --- /dev/null +++ b/tests/engine/SharedSnapshotService.test.ts @@ -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 { + 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 = {}): SharedSnapshotPostEngine { + const byId = new Map(posts.map((post) => [post.id, post])); + + return { + async getPost(id: string): Promise { + return byId.get(id) ?? null; + }, + async getPublishedVersion(id: string): Promise { + return snapshotsById[id] ?? null; + }, + async getPostsFiltered(filter: PostFilter): Promise { + 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(); + }); +}); diff --git a/tests/engine/SiteValidationDiffService.test.ts b/tests/engine/SiteValidationDiffService.test.ts new file mode 100644 index 0000000..4a1d50d --- /dev/null +++ b/tests/engine/SiteValidationDiffService.test.ts @@ -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'), 'root', 'utf-8'); + await writeFile(path.join(htmlDir, 'category', 'news', 'index.html'), 'news', 'utf-8'); + await writeFile(path.join(htmlDir, 'category', 'news', 'page', '2', 'index.html'), 'news p2', 'utf-8'); + await writeFile(path.join(htmlDir, 'stale', 'index.html'), 'stale', 'utf-8'); + + const sitemapXml = [ + '', + '', + ' https://example.com/', + ' https://example.com/category/news/', + ' https://example.com/category/news/page/2/', + ' https://example.com/tag/dev/', + '', + '', + ].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 = [ + '', + '', + ' https://example.com/blog/', + ' https://example.com/blog/page/2/', + '', + '', + ].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); + }); +}); diff --git a/tests/engine/ValidationApplyPlannerService.test.ts b/tests/engine/ValidationApplyPlannerService.test.ts new file mode 100644 index 0000000..f1a35be --- /dev/null +++ b/tests/engine/ValidationApplyPlannerService.test.ts @@ -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 { + 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); + }); +});