chore: refactorings and code sharing
This commit is contained in:
File diff suppressed because it is too large
Load Diff
73
src/main/engine/GenerationPostSnapshotService.ts
Normal file
73
src/main/engine/GenerationPostSnapshotService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { PostData } from './PostEngine';
|
||||
|
||||
export interface GenerationSnapshotPostEngine {
|
||||
getPostsFiltered: (filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }) => Promise<PostData[]>;
|
||||
getPublishedVersion: (id: string) => Promise<PostData | null>;
|
||||
}
|
||||
|
||||
export interface GenerationPublishedSets {
|
||||
publishedPosts: PostData[];
|
||||
publishedListPosts: PostData[];
|
||||
}
|
||||
|
||||
export async function loadPublishedGenerationSets(
|
||||
postEngine: GenerationSnapshotPostEngine,
|
||||
listExcludedCategories: string[],
|
||||
): Promise<GenerationPublishedSets> {
|
||||
const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' });
|
||||
const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
|
||||
const publishedListCandidates = await postEngine.getPostsFiltered({
|
||||
status: 'published',
|
||||
excludeCategories: listExcludedCategories,
|
||||
});
|
||||
const draftListCandidates = await postEngine.getPostsFiltered({
|
||||
status: 'draft',
|
||||
excludeCategories: listExcludedCategories,
|
||||
});
|
||||
|
||||
const publishedSnapshots = await Promise.all(
|
||||
publishedCandidates.map(async (post) => {
|
||||
const snapshot = await postEngine.getPublishedVersion(post.id);
|
||||
return snapshot || post;
|
||||
}),
|
||||
);
|
||||
const draftPublishedSnapshots = await Promise.all(
|
||||
draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
|
||||
);
|
||||
const publishedListSnapshots = await Promise.all(
|
||||
publishedListCandidates.map(async (post) => {
|
||||
const snapshot = await postEngine.getPublishedVersion(post.id);
|
||||
return snapshot || post;
|
||||
}),
|
||||
);
|
||||
const draftListPublishedSnapshots = await Promise.all(
|
||||
draftListCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
|
||||
);
|
||||
|
||||
const publishedPostById = new Map<string, PostData>();
|
||||
for (const post of publishedSnapshots) {
|
||||
publishedPostById.set(post.id, post);
|
||||
}
|
||||
for (const snapshot of draftPublishedSnapshots) {
|
||||
if (snapshot) {
|
||||
publishedPostById.set(snapshot.id, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
const publishedListPostById = new Map<string, PostData>();
|
||||
for (const post of publishedListSnapshots) {
|
||||
publishedListPostById.set(post.id, post);
|
||||
}
|
||||
for (const snapshot of draftListPublishedSnapshots) {
|
||||
if (snapshot) {
|
||||
publishedListPostById.set(snapshot.id, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
publishedPosts: Array.from(publishedPostById.values())
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
|
||||
publishedListPosts: Array.from(publishedListPostById.values())
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
|
||||
};
|
||||
}
|
||||
375
src/main/engine/GenerationSitemapFeedService.ts
Normal file
375
src/main/engine/GenerationSitemapFeedService.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import type { PostData } from './PostEngine';
|
||||
|
||||
export interface GenerationPostIndexLike {
|
||||
postsByCategory: Map<string, PostData[]>;
|
||||
postsByTag: Map<string, PostData[]>;
|
||||
postsByYear: Map<number, PostData[]>;
|
||||
postsByYearMonth: Map<string, PostData[]>;
|
||||
postsByYearMonthDay: Map<string, PostData[]>;
|
||||
}
|
||||
|
||||
interface BuildSitemapAndFeedsParams {
|
||||
baseUrl: string;
|
||||
projectName: string;
|
||||
projectDescription?: string;
|
||||
maxPostsPerPage: number;
|
||||
publishedPosts: PostData[];
|
||||
publishedListPosts: PostData[];
|
||||
postIndex: GenerationPostIndexLike;
|
||||
includeFeeds: boolean;
|
||||
}
|
||||
|
||||
export interface SitemapFeedBuildResult {
|
||||
allTags: Set<string>;
|
||||
allCategories: Set<string>;
|
||||
yearMonths: Map<string, Date>;
|
||||
years: Map<number, Date>;
|
||||
yearMonthDays: Map<string, Date>;
|
||||
urls: string[];
|
||||
sitemapXml: string;
|
||||
rssXml: string;
|
||||
atomXml: string;
|
||||
feedPosts: PostData[];
|
||||
}
|
||||
|
||||
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||
if (post.createdAt instanceof Date) {
|
||||
return post.createdAt;
|
||||
}
|
||||
|
||||
const parsed = new Date(post.createdAt);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
|
||||
const year = createdAt.getFullYear();
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||
return `/${year}/${month}/${day}/${slug}`;
|
||||
}
|
||||
|
||||
function escapeXml(value: unknown): string {
|
||||
const str = typeof value === 'string' ? value : value == null ? '' : String(value);
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function buildSitemapUrl(
|
||||
loc: string,
|
||||
lastmod: string,
|
||||
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
||||
priority: string,
|
||||
): string {
|
||||
const canonicalLoc = (() => {
|
||||
try {
|
||||
const parsed = new URL(loc);
|
||||
if (!parsed.pathname.endsWith('/')) {
|
||||
parsed.pathname = `${parsed.pathname}/`;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return loc.endsWith('/') ? loc : `${loc}/`;
|
||||
}
|
||||
})();
|
||||
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${escapeXml(canonicalLoc)}</loc>`,
|
||||
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
|
||||
` <changefreq>${changefreq}</changefreq>`,
|
||||
` <priority>${priority}</priority>`,
|
||||
' </url>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function appendPaginatedSitemapUrls(
|
||||
target: string[],
|
||||
baseUrl: string,
|
||||
basePath: string,
|
||||
totalItems: number,
|
||||
maxPostsPerPage: number,
|
||||
lastmod: string,
|
||||
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
||||
priority: string,
|
||||
): void {
|
||||
if (totalItems <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / maxPostsPerPage));
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
const normalizedBase = basePath.replace(/\/+$/, '');
|
||||
const pagePath = `${normalizedBase}/page/${page}`;
|
||||
target.push(buildSitemapUrl(`${baseUrl}${pagePath}`, lastmod, changefreq, priority));
|
||||
}
|
||||
}
|
||||
|
||||
function splitParagraphs(markdown: string | null | undefined): string[] {
|
||||
const normalizedMarkdown = typeof markdown === 'string' ? markdown : '';
|
||||
return normalizedMarkdown
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter((paragraph) => paragraph.length > 0);
|
||||
}
|
||||
|
||||
function paragraphToXhtml(paragraph: string): string {
|
||||
const escaped = escapeXml(paragraph).replace(/\n/g, '<br />');
|
||||
return `<p>${escaped}</p>`;
|
||||
}
|
||||
|
||||
function markdownToXhtml(markdown: string): string {
|
||||
const paragraphs = splitParagraphs(markdown);
|
||||
if (paragraphs.length === 0) {
|
||||
return '<p></p>';
|
||||
}
|
||||
return paragraphs.map(paragraphToXhtml).join('');
|
||||
}
|
||||
|
||||
function excerptToXhtml(post: PostData): string {
|
||||
if (typeof post.excerpt === 'string' && post.excerpt.trim().length > 0) {
|
||||
return paragraphToXhtml(post.excerpt.trim());
|
||||
}
|
||||
const firstParagraph = splitParagraphs(post.content)[0] || '';
|
||||
return paragraphToXhtml(firstParagraph);
|
||||
}
|
||||
|
||||
function escapeCdata(value: string): string {
|
||||
return value.replace(/]]>/g, ']]]]><![CDATA[>');
|
||||
}
|
||||
|
||||
export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): SitemapFeedBuildResult {
|
||||
const {
|
||||
baseUrl,
|
||||
projectName,
|
||||
projectDescription,
|
||||
maxPostsPerPage,
|
||||
publishedPosts,
|
||||
publishedListPosts,
|
||||
postIndex,
|
||||
includeFeeds,
|
||||
} = params;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const allTags = new Set<string>();
|
||||
const allCategories = new Set<string>();
|
||||
const yearMonths = new Map<string, Date>();
|
||||
const years = new Map<number, Date>();
|
||||
const yearMonthDays = new Map<string, Date>();
|
||||
const postUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||
const pageUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||
const postUrl = `${baseUrl}${canonicalPath}`;
|
||||
const updatedAt = post.updatedAt;
|
||||
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
|
||||
|
||||
const categories = Array.isArray(post.categories) ? post.categories : [];
|
||||
if (categories.includes('page')) {
|
||||
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
|
||||
if (trimmedSlug.length > 0) {
|
||||
pageUrls.push({
|
||||
loc: `${baseUrl}/${trimmedSlug}`,
|
||||
lastmod: updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const post of publishedListPosts) {
|
||||
for (const tag of post.tags || []) allTags.add(tag);
|
||||
for (const category of post.categories || []) allCategories.add(category);
|
||||
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const updatedAt = post.updatedAt;
|
||||
|
||||
const year = createdAt.getFullYear();
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||
const ymKey = `${year}/${month}`;
|
||||
const ymdKey = `${year}/${month}/${day}`;
|
||||
|
||||
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
||||
yearMonths.set(ymKey, updatedAt);
|
||||
}
|
||||
if (!years.has(year) || updatedAt > years.get(year)!) {
|
||||
years.set(year, updatedAt);
|
||||
}
|
||||
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
||||
yearMonthDays.set(ymdKey, updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
|
||||
|
||||
const urls: string[] = [];
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
|
||||
appendPaginatedSitemapUrls(urls, baseUrl, '', publishedListPosts.length, maxPostsPerPage, latestPostUpdatedAt, 'daily', '0.9');
|
||||
for (const post of postUrls) {
|
||||
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
|
||||
}
|
||||
for (const page of pageUrls) {
|
||||
urls.push(buildSitemapUrl(page.loc, page.lastmod, 'weekly', '0.7'));
|
||||
}
|
||||
|
||||
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||
|
||||
const yearCount = postIndex.postsByYear.get(year)?.length ?? 0;
|
||||
appendPaginatedSitemapUrls(urls, baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
|
||||
}
|
||||
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||
|
||||
const monthCount = postIndex.postsByYearMonth.get(ym)?.length ?? 0;
|
||||
appendPaginatedSitemapUrls(urls, baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
|
||||
}
|
||||
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
||||
|
||||
const dayCount = postIndex.postsByYearMonthDay.get(ymd)?.length ?? 0;
|
||||
appendPaginatedSitemapUrls(urls, baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3');
|
||||
}
|
||||
|
||||
for (const category of Array.from(allCategories).sort()) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||
|
||||
const categoryCount = postIndex.postsByCategory.get(category)?.length ?? 0;
|
||||
appendPaginatedSitemapUrls(urls, baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
|
||||
}
|
||||
|
||||
for (const tag of Array.from(allTags).sort()) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||
|
||||
const tagCount = postIndex.postsByTag.get(tag)?.length ?? 0;
|
||||
appendPaginatedSitemapUrls(urls, baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
|
||||
}
|
||||
|
||||
const sitemapXml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...urls,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const feedPosts = publishedListPosts.slice(0, maxPostsPerPage);
|
||||
if (!includeFeeds) {
|
||||
return {
|
||||
allTags,
|
||||
allCategories,
|
||||
yearMonths,
|
||||
years,
|
||||
yearMonthDays,
|
||||
urls,
|
||||
sitemapXml,
|
||||
rssXml: '',
|
||||
atomXml: '',
|
||||
feedPosts,
|
||||
};
|
||||
}
|
||||
|
||||
const feedUpdatedAt = feedPosts[0]?.updatedAt || new Date();
|
||||
const baseLink = `${baseUrl}/`;
|
||||
const feedTitle = projectName;
|
||||
const feedDescription = projectDescription?.trim() || feedTitle;
|
||||
|
||||
const rssItems = feedPosts.map((post) => {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||
const permalink = `${baseUrl}${canonicalPath}`;
|
||||
const excerptXhtml = excerptToXhtml(post);
|
||||
const contentXhtml = markdownToXhtml(post.content || '');
|
||||
const categories = [
|
||||
...(post.categories || []).map((category) => `<category>${escapeXml(category)}</category>`),
|
||||
...(post.tags || []).map((tag) => `<category>${escapeXml(tag)}</category>`),
|
||||
];
|
||||
|
||||
return [
|
||||
' <item>',
|
||||
` <title>${escapeXml(post.title)}</title>`,
|
||||
` <link>${escapeXml(permalink)}</link>`,
|
||||
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
|
||||
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
|
||||
post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
|
||||
` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`,
|
||||
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
|
||||
...categories.map((entry) => ` ${entry}`),
|
||||
' </item>',
|
||||
].filter(Boolean).join('\n');
|
||||
});
|
||||
|
||||
const rssXml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
|
||||
' <channel>',
|
||||
` <title>${escapeXml(feedTitle)}</title>`,
|
||||
` <link>${escapeXml(baseLink)}</link>`,
|
||||
` <description>${escapeXml(feedDescription)}</description>`,
|
||||
` <lastBuildDate>${feedUpdatedAt.toUTCString()}</lastBuildDate>`,
|
||||
' <generator>bDS</generator>',
|
||||
...rssItems,
|
||||
' </channel>',
|
||||
'</rss>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const atomEntries = feedPosts.map((post) => {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||
const permalink = `${baseUrl}${canonicalPath}`;
|
||||
const excerptXhtml = excerptToXhtml(post);
|
||||
const contentXhtml = markdownToXhtml(post.content || '');
|
||||
const categories = [
|
||||
...(post.tags || []).map((tag) => `<category term="${escapeXml(tag)}" />`),
|
||||
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
|
||||
];
|
||||
|
||||
return [
|
||||
' <entry>',
|
||||
` <title>${escapeXml(post.title)}</title>`,
|
||||
` <id>${escapeXml(permalink)}</id>`,
|
||||
` <link href="${escapeXml(permalink)}" />`,
|
||||
` <updated>${post.updatedAt.toISOString()}</updated>`,
|
||||
` <published>${(post.publishedAt || post.updatedAt).toISOString()}</published>`,
|
||||
post.author ? ` <author><name>${escapeXml(post.author)}</name></author>` : null,
|
||||
` <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${excerptXhtml}</div></summary>`,
|
||||
` <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${contentXhtml}</div></content>`,
|
||||
...categories.map((entry) => ` ${entry}`),
|
||||
' </entry>',
|
||||
].filter(Boolean).join('\n');
|
||||
});
|
||||
|
||||
const atomXml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<feed xmlns="http://www.w3.org/2005/Atom">',
|
||||
` <title>${escapeXml(feedTitle)}</title>`,
|
||||
` <subtitle>${escapeXml(feedDescription)}</subtitle>`,
|
||||
` <id>${escapeXml(baseLink)}</id>`,
|
||||
` <link href="${escapeXml(baseLink)}" rel="alternate" />`,
|
||||
` <link href="${escapeXml(`${baseLink}atom.xml`)}" rel="self" />`,
|
||||
` <updated>${feedUpdatedAt.toISOString()}</updated>`,
|
||||
...atomEntries,
|
||||
'</feed>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
allTags,
|
||||
allCategories,
|
||||
yearMonths,
|
||||
years,
|
||||
yearMonthDays,
|
||||
urls,
|
||||
sitemapXml,
|
||||
rssXml,
|
||||
atomXml,
|
||||
feedPosts,
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
buildTemplateMenuItems,
|
||||
buildCanonicalPostPath,
|
||||
clampMaxPostsPerPage,
|
||||
parseRoutePagination,
|
||||
resolvePageTitle,
|
||||
type CategoryRenderSettings,
|
||||
type HtmlRewriteContext,
|
||||
@@ -23,6 +22,12 @@ import {
|
||||
} from './PageRenderer';
|
||||
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
||||
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
||||
import {
|
||||
findSinglePostBySlug,
|
||||
loadPostsForDayPage,
|
||||
loadPublishedSnapshots,
|
||||
loadPublishedSnapshotsPage,
|
||||
} from './SharedSnapshotService';
|
||||
|
||||
interface ActiveProjectContext {
|
||||
projectId: string;
|
||||
@@ -179,10 +184,10 @@ export class PreviewServer {
|
||||
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
|
||||
pageRenderer: this.pageRenderer,
|
||||
postEngineForMacros: this.postEngine,
|
||||
loadPublishedSnapshotsPage: (filter, pagination) => this.loadPublishedSnapshotsPage(filter, pagination),
|
||||
loadPublishedSnapshots: (filter, pagination) => this.loadPublishedSnapshots(filter, pagination),
|
||||
loadPostsForDayPage: (year, month, day, pagination) => this.loadPostsForDayPage(year, month, day, pagination),
|
||||
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => this.findSinglePostBySlug(slug, singlePostOptions, dateFilter),
|
||||
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
|
||||
loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
|
||||
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
|
||||
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,190 +299,13 @@ export class PreviewServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRoute(
|
||||
pathname: string,
|
||||
maxPostsPerPage: number,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
categoryMetadata: Record<string, CategoryMetadata>,
|
||||
listExcludedCategories: string[],
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||
): Promise<string | null> {
|
||||
const routePagination = parseRoutePagination(pathname);
|
||||
if (!routePagination) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pagedPathname = routePagination.pathname;
|
||||
const page = routePagination.page;
|
||||
const pageOptions = {
|
||||
maxPostsPerPage,
|
||||
page,
|
||||
};
|
||||
|
||||
if (pagedPathname === '/') {
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions);
|
||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'date',
|
||||
archiveContext: { kind: 'root' },
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/);
|
||||
if (tagMatch) {
|
||||
const tag = tagMatch[1];
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', tags: [tag], excludeCategories: listExcludedCategories }, pageOptions);
|
||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'non-date',
|
||||
archiveContext: { kind: 'tag', name: tag },
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
|
||||
if (categoryMatch) {
|
||||
const category = categoryMatch[1];
|
||||
const categoryDisplayTitle = categoryMetadata[category]?.title?.trim() || category;
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions);
|
||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'non-date',
|
||||
archiveContext: { kind: 'category', name: categoryDisplayTitle },
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
const daySlugMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/);
|
||||
if (daySlugMatch) {
|
||||
const year = Number(daySlugMatch[1]);
|
||||
const month = Number(daySlugMatch[2]);
|
||||
const day = Number(daySlugMatch[3]);
|
||||
const slug = daySlugMatch[4];
|
||||
const post = await this.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day });
|
||||
if (!post) return null;
|
||||
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
const dayMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
|
||||
if (dayMatch) {
|
||||
const year = Number(dayMatch[1]);
|
||||
const month = Number(dayMatch[2]);
|
||||
const day = Number(dayMatch[3]);
|
||||
const result = await this.loadPostsForDayPage(year, month, day, {
|
||||
...pageOptions,
|
||||
excludeCategories: listExcludedCategories,
|
||||
});
|
||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'date',
|
||||
archiveContext: { kind: 'day', year, month, day },
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
const monthMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})$/);
|
||||
if (monthMatch) {
|
||||
const year = Number(monthMatch[1]);
|
||||
const month = Number(monthMatch[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions);
|
||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'date',
|
||||
archiveContext: { kind: 'month', year, month },
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
const yearMatch = pagedPathname.match(/^\/(\d{4})$/);
|
||||
if (yearMatch) {
|
||||
const year = Number(yearMatch[1]);
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, excludeCategories: listExcludedCategories }, pageOptions);
|
||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'date',
|
||||
archiveContext: { kind: 'year', year },
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
|
||||
if (pageSlugMatch) {
|
||||
const slug = pageSlugMatch[1];
|
||||
const pages = await this.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
|
||||
const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
|
||||
if (!pagePost) return null;
|
||||
return this.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async renderStylePreview(
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
listExcludedCategories: string[],
|
||||
): Promise<string> {
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, {
|
||||
const result = await loadPublishedSnapshotsPage(this.postEngine, { status: 'published', excludeCategories: listExcludedCategories }, {
|
||||
maxPostsPerPage: 10,
|
||||
page: 1,
|
||||
});
|
||||
@@ -507,190 +335,8 @@ export class PreviewServer {
|
||||
}, this.postEngine);
|
||||
}
|
||||
|
||||
private async findPublishedPostBySlug(slug: string, dateFilter?: { year: number; month: number }): Promise<PostData | null> {
|
||||
if (!slug) return null;
|
||||
|
||||
if (this.postEngine.findPublishedBySlug) {
|
||||
const directMatch = await this.postEngine.findPublishedBySlug(slug, dateFilter);
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
}
|
||||
|
||||
const filter: PostFilter = {
|
||||
...(dateFilter ? { year: dateFilter.year, month: dateFilter.month } : {}),
|
||||
};
|
||||
|
||||
const candidates = await this.loadPublishedSnapshots(filter);
|
||||
const match = candidates.find((candidate) => candidate.slug === slug);
|
||||
if (!match) return null;
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
private async findSinglePostBySlug(
|
||||
slug: string,
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||
dateFilter?: { year: number; month: number; day?: number },
|
||||
): Promise<PostData | null> {
|
||||
if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) {
|
||||
const draftCandidate = await this.postEngine.getPost(singlePostOptions.draftPostId);
|
||||
if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) {
|
||||
if (!dateFilter) {
|
||||
return draftCandidate;
|
||||
}
|
||||
|
||||
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
|
||||
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month;
|
||||
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
|
||||
if (sameYear && sameMonth && sameDay) {
|
||||
return draftCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
|
||||
return this.findPublishedPostBySlug(slug, fallbackDateFilter);
|
||||
}
|
||||
|
||||
private async loadPostsForDay(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): Promise<PostData[]> {
|
||||
const result = await this.loadPostsForDayPage(year, month, day, pagination);
|
||||
return result.posts;
|
||||
}
|
||||
|
||||
private async loadPostsForDayPage(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): Promise<{ posts: PostData[]; totalPosts: number }> {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return { posts: [], totalPosts: 0 };
|
||||
}
|
||||
|
||||
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||
|
||||
const result = await this.loadPublishedSnapshotsPage({
|
||||
status: 'published',
|
||||
excludeCategories: pagination?.excludeCategories,
|
||||
startDate,
|
||||
endDate,
|
||||
}, pagination);
|
||||
|
||||
const posts = result.posts.filter((post) => {
|
||||
const createdAt = post.createdAt;
|
||||
return createdAt.getFullYear() === year
|
||||
&& createdAt.getMonth() === month - 1
|
||||
&& createdAt.getDate() === day;
|
||||
});
|
||||
|
||||
return {
|
||||
posts,
|
||||
totalPosts: result.totalPosts,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
|
||||
const baseFilter: PostFilter = {};
|
||||
|
||||
if (filter.startDate) baseFilter.startDate = filter.startDate;
|
||||
if (filter.endDate) baseFilter.endDate = filter.endDate;
|
||||
if (filter.year !== undefined) baseFilter.year = filter.year;
|
||||
if (filter.month !== undefined) baseFilter.month = filter.month;
|
||||
|
||||
return baseFilter;
|
||||
}
|
||||
|
||||
private async toPublishedSnapshot(post: PostData): Promise<PostData | null> {
|
||||
if (post.status === 'published') {
|
||||
return post;
|
||||
}
|
||||
|
||||
if (post.status === 'draft') {
|
||||
return await this.postEngine.getPublishedVersion(post.id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async loadPublishedSnapshots(
|
||||
filter: PostFilter,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): Promise<PostData[]> {
|
||||
const result = await this.loadPublishedSnapshotsPage(filter, pagination);
|
||||
return result.posts;
|
||||
}
|
||||
|
||||
private paginateSnapshots(
|
||||
snapshots: PostData[],
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): { posts: PostData[]; totalPosts: number } {
|
||||
const totalPosts = snapshots.length;
|
||||
|
||||
if (typeof pagination?.maxPostsPerPage !== 'number') {
|
||||
return { posts: snapshots, totalPosts };
|
||||
}
|
||||
|
||||
const maxPostsPerPage = pagination.maxPostsPerPage;
|
||||
const page = Number.isInteger(pagination.page) && (pagination.page ?? 0) > 0
|
||||
? (pagination.page as number)
|
||||
: 1;
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
|
||||
return {
|
||||
posts: snapshots.slice(offset, offset + maxPostsPerPage),
|
||||
totalPosts,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPublishedSnapshotsPage(
|
||||
filter: PostFilter,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): Promise<{ posts: PostData[]; totalPosts: number }> {
|
||||
if (filter.status && filter.status !== 'published') {
|
||||
return { posts: [], totalPosts: 0 };
|
||||
}
|
||||
|
||||
const baseFilter = this.buildSnapshotBaseFilter(filter);
|
||||
const publishedCandidates = await this.postEngine.getPostsFiltered({
|
||||
...baseFilter,
|
||||
status: 'published',
|
||||
excludeCategories: filter.excludeCategories,
|
||||
});
|
||||
const draftCandidates = await this.postEngine.getPostsFiltered({
|
||||
...baseFilter,
|
||||
status: 'draft',
|
||||
excludeCategories: filter.excludeCategories,
|
||||
});
|
||||
|
||||
const snapshotCandidates = await Promise.all([
|
||||
...publishedCandidates.map((post) => this.toPublishedSnapshot(post)),
|
||||
...draftCandidates.map((post) => this.toPublishedSnapshot(post)),
|
||||
]);
|
||||
|
||||
let snapshots = snapshotCandidates.filter((post): post is PostData => post !== null);
|
||||
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
snapshots = snapshots.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag)));
|
||||
}
|
||||
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
snapshots = snapshots.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return this.paginateSnapshots(snapshots, pagination);
|
||||
}
|
||||
|
||||
private async buildHtmlRewriteContext(): Promise<HtmlRewriteContext> {
|
||||
const publishedPosts = await this.loadPublishedSnapshots({ status: 'published' });
|
||||
const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' });
|
||||
const canonicalPostPathBySlug = new Map<string, string>();
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
|
||||
190
src/main/engine/SharedSnapshotService.ts
Normal file
190
src/main/engine/SharedSnapshotService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { PostData, PostFilter } from './PostEngine';
|
||||
|
||||
export interface SharedSnapshotPostEngine {
|
||||
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
getPublishedVersion: (id: string) => Promise<PostData | null>;
|
||||
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
||||
}
|
||||
|
||||
function buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
|
||||
const baseFilter: PostFilter = {};
|
||||
|
||||
if (filter.startDate) baseFilter.startDate = filter.startDate;
|
||||
if (filter.endDate) baseFilter.endDate = filter.endDate;
|
||||
if (filter.year !== undefined) baseFilter.year = filter.year;
|
||||
if (filter.month !== undefined) baseFilter.month = filter.month;
|
||||
|
||||
return baseFilter;
|
||||
}
|
||||
|
||||
async function toPublishedSnapshot(postEngine: SharedSnapshotPostEngine, post: PostData): Promise<PostData | null> {
|
||||
if (post.status === 'published') {
|
||||
return post;
|
||||
}
|
||||
|
||||
if (post.status === 'draft') {
|
||||
return await postEngine.getPublishedVersion(post.id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function paginateSnapshots(
|
||||
snapshots: PostData[],
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): { posts: PostData[]; totalPosts: number } {
|
||||
const totalPosts = snapshots.length;
|
||||
|
||||
if (typeof pagination?.maxPostsPerPage !== 'number') {
|
||||
return { posts: snapshots, totalPosts };
|
||||
}
|
||||
|
||||
const maxPostsPerPage = pagination.maxPostsPerPage;
|
||||
const page = Number.isInteger(pagination.page) && (pagination.page ?? 0) > 0
|
||||
? (pagination.page as number)
|
||||
: 1;
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
|
||||
return {
|
||||
posts: snapshots.slice(offset, offset + maxPostsPerPage),
|
||||
totalPosts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPublishedSnapshotsPage(
|
||||
postEngine: SharedSnapshotPostEngine,
|
||||
filter: PostFilter,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): Promise<{ posts: PostData[]; totalPosts: number }> {
|
||||
if (filter.status && filter.status !== 'published') {
|
||||
return { posts: [], totalPosts: 0 };
|
||||
}
|
||||
|
||||
const baseFilter = buildSnapshotBaseFilter(filter);
|
||||
const publishedCandidates = await postEngine.getPostsFiltered({
|
||||
...baseFilter,
|
||||
status: 'published',
|
||||
excludeCategories: filter.excludeCategories,
|
||||
});
|
||||
const draftCandidates = await postEngine.getPostsFiltered({
|
||||
...baseFilter,
|
||||
status: 'draft',
|
||||
excludeCategories: filter.excludeCategories,
|
||||
});
|
||||
|
||||
const snapshotCandidates = await Promise.all([
|
||||
...publishedCandidates.map((post) => toPublishedSnapshot(postEngine, post)),
|
||||
...draftCandidates.map((post) => toPublishedSnapshot(postEngine, post)),
|
||||
]);
|
||||
|
||||
let snapshots = snapshotCandidates.filter((post): post is PostData => post !== null);
|
||||
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
snapshots = snapshots.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag)));
|
||||
}
|
||||
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
snapshots = snapshots.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return paginateSnapshots(snapshots, pagination);
|
||||
}
|
||||
|
||||
export async function loadPublishedSnapshots(
|
||||
postEngine: SharedSnapshotPostEngine,
|
||||
filter: PostFilter,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): Promise<PostData[]> {
|
||||
const result = await loadPublishedSnapshotsPage(postEngine, filter, pagination);
|
||||
return result.posts;
|
||||
}
|
||||
|
||||
export async function loadPostsForDayPage(
|
||||
postEngine: SharedSnapshotPostEngine,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
): Promise<{ posts: PostData[]; totalPosts: number }> {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return { posts: [], totalPosts: 0 };
|
||||
}
|
||||
|
||||
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||
|
||||
const result = await loadPublishedSnapshotsPage(
|
||||
postEngine,
|
||||
{
|
||||
status: 'published',
|
||||
excludeCategories: pagination?.excludeCategories,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
pagination,
|
||||
);
|
||||
|
||||
const posts = result.posts.filter((post) => {
|
||||
const createdAt = post.createdAt;
|
||||
return createdAt.getFullYear() === year
|
||||
&& createdAt.getMonth() === month - 1
|
||||
&& createdAt.getDate() === day;
|
||||
});
|
||||
|
||||
return {
|
||||
posts,
|
||||
totalPosts: result.totalPosts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findPublishedPostBySlug(
|
||||
postEngine: SharedSnapshotPostEngine,
|
||||
slug: string,
|
||||
dateFilter?: { year: number; month: number },
|
||||
): Promise<PostData | null> {
|
||||
if (!slug) return null;
|
||||
|
||||
if (postEngine.findPublishedBySlug) {
|
||||
const directMatch = await postEngine.findPublishedBySlug(slug, dateFilter);
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
}
|
||||
|
||||
const filter: PostFilter = {
|
||||
...(dateFilter ? { year: dateFilter.year, month: dateFilter.month } : {}),
|
||||
};
|
||||
|
||||
const candidates = await loadPublishedSnapshots(postEngine, filter);
|
||||
const match = candidates.find((candidate) => candidate.slug === slug);
|
||||
return match ?? null;
|
||||
}
|
||||
|
||||
export async function findSinglePostBySlug(
|
||||
postEngine: SharedSnapshotPostEngine,
|
||||
slug: string,
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||
dateFilter?: { year: number; month: number; day?: number },
|
||||
): Promise<PostData | null> {
|
||||
if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) {
|
||||
const draftCandidate = await postEngine.getPost(singlePostOptions.draftPostId);
|
||||
if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) {
|
||||
if (!dateFilter) {
|
||||
return draftCandidate;
|
||||
}
|
||||
|
||||
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
|
||||
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month;
|
||||
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
|
||||
if (sameYear && sameMonth && sameDay) {
|
||||
return draftCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
|
||||
return findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
|
||||
}
|
||||
114
src/main/engine/SiteValidationDiffService.ts
Normal file
114
src/main/engine/SiteValidationDiffService.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface SiteValidationDiffResult {
|
||||
missingUrlPaths: string[];
|
||||
extraUrlPaths: string[];
|
||||
expectedUrlCount: number;
|
||||
existingHtmlUrlCount: number;
|
||||
}
|
||||
|
||||
interface CompareSitemapToHtmlParams {
|
||||
sitemapXml: string;
|
||||
baseUrl: string;
|
||||
htmlDir: string;
|
||||
}
|
||||
|
||||
function normalizeUrlPath(urlPath: string): string {
|
||||
const trimmed = (urlPath || '').trim();
|
||||
if (!trimmed || trimmed === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const noQuery = trimmed.split('?')[0]?.split('#')[0] ?? '';
|
||||
const withoutSlashes = noQuery.replace(/^\/+|\/+$/g, '');
|
||||
return withoutSlashes ? `/${withoutSlashes}` : '/';
|
||||
}
|
||||
|
||||
function sitemapLocToProjectPath(loc: string, baseUrl: string): string {
|
||||
try {
|
||||
const locUrl = new URL(loc);
|
||||
const base = new URL(baseUrl);
|
||||
const locPath = locUrl.pathname.replace(/\/+$/, '');
|
||||
const basePath = base.pathname.replace(/\/+$/, '');
|
||||
|
||||
if (basePath && locPath.startsWith(basePath)) {
|
||||
const stripped = locPath.slice(basePath.length);
|
||||
return normalizeUrlPath(stripped || '/');
|
||||
}
|
||||
|
||||
return normalizeUrlPath(locPath || '/');
|
||||
} catch {
|
||||
return normalizeUrlPath(loc);
|
||||
}
|
||||
}
|
||||
|
||||
function extractSitemapLocs(sitemapXml: string): string[] {
|
||||
const matches = sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g);
|
||||
const locs: string[] = [];
|
||||
for (const match of matches) {
|
||||
const value = match[1]?.trim();
|
||||
if (value) {
|
||||
locs.push(value);
|
||||
}
|
||||
}
|
||||
return locs;
|
||||
}
|
||||
|
||||
async function collectHtmlIndexPaths(htmlDir: string): Promise<Set<string>> {
|
||||
const existingHtmlPathSet = new Set<string>();
|
||||
|
||||
const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const nextRelative = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
|
||||
const nextPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await collectIndexPaths(nextPath, nextRelative);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || entry.name !== 'index.html') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRelative = nextRelative.replace(/(^|\/)index\.html$/, '');
|
||||
existingHtmlPathSet.add(normalizeUrlPath(normalizedRelative ? `/${normalizedRelative}` : '/'));
|
||||
}
|
||||
};
|
||||
|
||||
await collectIndexPaths(htmlDir);
|
||||
return existingHtmlPathSet;
|
||||
}
|
||||
|
||||
export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams): Promise<SiteValidationDiffResult> {
|
||||
const expectedPathSet = new Set(
|
||||
extractSitemapLocs(params.sitemapXml)
|
||||
.map((loc) => sitemapLocToProjectPath(loc, params.baseUrl))
|
||||
.map((value) => normalizeUrlPath(value)),
|
||||
);
|
||||
|
||||
const existingHtmlPathSet = await collectHtmlIndexPaths(params.htmlDir);
|
||||
|
||||
const missingUrlPaths = Array.from(expectedPathSet)
|
||||
.filter((value) => !existingHtmlPathSet.has(value))
|
||||
.sort();
|
||||
|
||||
const extraUrlPaths = Array.from(existingHtmlPathSet)
|
||||
.filter((value) => !expectedPathSet.has(value))
|
||||
.sort();
|
||||
|
||||
return {
|
||||
missingUrlPaths,
|
||||
extraUrlPaths,
|
||||
expectedUrlCount: expectedPathSet.size,
|
||||
existingHtmlUrlCount: existingHtmlPathSet.size,
|
||||
};
|
||||
}
|
||||
243
src/main/engine/ValidationApplyPlannerService.ts
Normal file
243
src/main/engine/ValidationApplyPlannerService.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { PostData } from './PostEngine';
|
||||
|
||||
export interface RequestedPostRoute {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface MissingPathPlan {
|
||||
requestedCategories: Set<string>;
|
||||
requestedTags: Set<string>;
|
||||
requestedYears: Set<number>;
|
||||
requestedYearMonths: Set<string>;
|
||||
requestedYearMonthDays: Set<string>;
|
||||
requestedPostRoutes: RequestedPostRoute[];
|
||||
requestedPageSlugs: Set<string>;
|
||||
requestRootRoutes: boolean;
|
||||
requiresFallbackSectionRender: boolean;
|
||||
}
|
||||
|
||||
export interface TargetedValidationPlan {
|
||||
requestedPostIds: Set<string>;
|
||||
requestedCategorySet: Set<string>;
|
||||
requestedTagSet: Set<string>;
|
||||
requestedYears: Set<number>;
|
||||
requestedYearMonths: Set<string>;
|
||||
requestedYearMonthDays: Set<string>;
|
||||
requestedPageSlugs: Set<string>;
|
||||
requestRootRoutes: boolean;
|
||||
}
|
||||
|
||||
function normalizeUrlPath(urlPath: string): string {
|
||||
const trimmed = (urlPath || '').trim();
|
||||
if (!trimmed || trimmed === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const noQuery = trimmed.split('?')[0]?.split('#')[0] ?? '';
|
||||
const withoutSlashes = noQuery.replace(/^\/+|\/+$/g, '');
|
||||
return withoutSlashes ? `/${withoutSlashes}` : '/';
|
||||
}
|
||||
|
||||
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||
if (post.createdAt instanceof Date) {
|
||||
return post.createdAt;
|
||||
}
|
||||
|
||||
const parsed = new Date(post.createdAt);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
function decodePathSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function planMissingValidationPaths(missingPaths: string[]): MissingPathPlan {
|
||||
const requestedCategories = new Set<string>();
|
||||
const requestedTags = new Set<string>();
|
||||
const requestedYears = new Set<number>();
|
||||
const requestedYearMonths = new Set<string>();
|
||||
const requestedYearMonthDays = new Set<string>();
|
||||
const requestedPostRoutes: RequestedPostRoute[] = [];
|
||||
const requestedPageSlugs = new Set<string>();
|
||||
let requestRootRoutes = false;
|
||||
let requiresFallbackSectionRender = false;
|
||||
|
||||
for (const missingPath of missingPaths) {
|
||||
const normalizedPath = normalizeUrlPath(missingPath);
|
||||
|
||||
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
|
||||
requestRootRoutes = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (categoryMatch) {
|
||||
requestedCategories.add(decodePathSegment(categoryMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (tagMatch) {
|
||||
requestedTags.add(decodePathSegment(tagMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
|
||||
if (singleMatch) {
|
||||
requestedPostRoutes.push({
|
||||
year: Number(singleMatch[1]),
|
||||
month: Number(singleMatch[2]),
|
||||
day: Number(singleMatch[3]),
|
||||
slug: decodePathSegment(singleMatch[4]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
|
||||
if (yearMatch) {
|
||||
requestedYears.add(Number(yearMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (monthMatch) {
|
||||
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (dayMatch) {
|
||||
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
|
||||
if (pageMatch) {
|
||||
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
requiresFallbackSectionRender = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
requestedCategories,
|
||||
requestedTags,
|
||||
requestedYears,
|
||||
requestedYearMonths,
|
||||
requestedYearMonthDays,
|
||||
requestedPostRoutes,
|
||||
requestedPageSlugs,
|
||||
requestRootRoutes,
|
||||
requiresFallbackSectionRender,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildTargetedValidationPlanParams {
|
||||
initialPlan: MissingPathPlan;
|
||||
publishedPosts: PostData[];
|
||||
allCategories: Set<string>;
|
||||
allTags: Set<string>;
|
||||
availableYearMonths: Iterable<string>;
|
||||
availableYearMonthDays: Iterable<string>;
|
||||
}
|
||||
|
||||
export function buildTargetedValidationPlan(params: BuildTargetedValidationPlanParams): TargetedValidationPlan {
|
||||
const {
|
||||
initialPlan,
|
||||
publishedPosts,
|
||||
allCategories,
|
||||
allTags,
|
||||
availableYearMonths,
|
||||
availableYearMonthDays,
|
||||
} = params;
|
||||
|
||||
const requestedCategories = new Set(initialPlan.requestedCategories);
|
||||
const requestedTags = new Set(initialPlan.requestedTags);
|
||||
const requestedYears = new Set(initialPlan.requestedYears);
|
||||
const requestedYearMonths = new Set(initialPlan.requestedYearMonths);
|
||||
const requestedYearMonthDays = new Set(initialPlan.requestedYearMonthDays);
|
||||
const requestedPostIds = new Set<string>();
|
||||
|
||||
for (const requestedRoute of initialPlan.requestedPostRoutes) {
|
||||
const routePost = publishedPosts.find((post) => {
|
||||
if (post.slug !== requestedRoute.slug) {
|
||||
return false;
|
||||
}
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
return createdAt.getFullYear() === requestedRoute.year
|
||||
&& (createdAt.getMonth() + 1) === requestedRoute.month
|
||||
&& createdAt.getDate() === requestedRoute.day;
|
||||
});
|
||||
|
||||
if (routePost) {
|
||||
requestedPostIds.add(routePost.id);
|
||||
for (const category of routePost.categories || []) {
|
||||
requestedCategories.add(category);
|
||||
}
|
||||
for (const tag of routePost.tags || []) {
|
||||
requestedTags.add(tag);
|
||||
}
|
||||
|
||||
const createdAt = resolvePostCreatedAt(routePost);
|
||||
const year = createdAt.getFullYear();
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||
requestedYears.add(year);
|
||||
requestedYearMonths.add(`${year}/${month}`);
|
||||
requestedYearMonthDays.add(`${year}/${month}/${day}`);
|
||||
} else {
|
||||
requestedYears.add(requestedRoute.year);
|
||||
requestedYearMonths.add(`${requestedRoute.year}/${String(requestedRoute.month).padStart(2, '0')}`);
|
||||
requestedYearMonthDays.add(`${requestedRoute.year}/${String(requestedRoute.month).padStart(2, '0')}/${String(requestedRoute.day).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const year of Array.from(requestedYears.values())) {
|
||||
for (const ym of availableYearMonths) {
|
||||
if (ym.startsWith(`${year}/`)) {
|
||||
requestedYearMonths.add(ym);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ym of Array.from(requestedYearMonths.values())) {
|
||||
for (const ymd of availableYearMonthDays) {
|
||||
if (ymd.startsWith(`${ym}/`)) {
|
||||
requestedYearMonthDays.add(ymd);
|
||||
}
|
||||
}
|
||||
|
||||
const [yearStr] = ym.split('/');
|
||||
requestedYears.add(Number(yearStr));
|
||||
}
|
||||
|
||||
for (const ymd of Array.from(requestedYearMonthDays.values())) {
|
||||
const [yearStr, monthStr] = ymd.split('/');
|
||||
requestedYears.add(Number(yearStr));
|
||||
requestedYearMonths.add(`${yearStr}/${monthStr}`);
|
||||
}
|
||||
|
||||
return {
|
||||
requestedPostIds,
|
||||
requestedCategorySet: new Set(
|
||||
Array.from(requestedCategories.values()).filter((category) => allCategories.has(category)),
|
||||
),
|
||||
requestedTagSet: new Set(
|
||||
Array.from(requestedTags.values()).filter((tag) => allTags.has(tag)),
|
||||
),
|
||||
requestedYears,
|
||||
requestedYearMonths,
|
||||
requestedYearMonthDays,
|
||||
requestedPageSlugs: new Set(initialPlan.requestedPageSlugs),
|
||||
requestRootRoutes: initialPlan.requestRootRoutes,
|
||||
};
|
||||
}
|
||||
78
tests/engine/GenerationPostSnapshotService.test.ts
Normal file
78
tests/engine/GenerationPostSnapshotService.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
import {
|
||||
loadPublishedGenerationSets,
|
||||
type GenerationSnapshotPostEngine,
|
||||
} from '../../src/main/engine/GenerationPostSnapshotService';
|
||||
|
||||
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
|
||||
const updatedAt = overrides.updatedAt ?? createdAt;
|
||||
const title = overrides.title ?? 'Title';
|
||||
|
||||
return {
|
||||
id: overrides.id ?? 'post-1',
|
||||
projectId: overrides.projectId ?? 'default',
|
||||
title,
|
||||
slug: overrides.slug ?? 'title',
|
||||
excerpt: overrides.excerpt,
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData | null> = {}): GenerationSnapshotPostEngine {
|
||||
return {
|
||||
async getPublishedVersion(postId: string): Promise<PostData | null> {
|
||||
return snapshotsById[postId] ?? null;
|
||||
},
|
||||
async getPostsFiltered(filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }): Promise<PostData[]> {
|
||||
return posts
|
||||
.filter((post) => {
|
||||
if (filter.status && post.status !== filter.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
|
||||
const categories = post.categories || [];
|
||||
if (categories.some((category) => filter.excludeCategories?.includes(category))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('GenerationPostSnapshotService', () => {
|
||||
it('loads published and list snapshots merged from published rows and draft published snapshots', async () => {
|
||||
const published = makePost({ id: 'pub-1', status: 'published', categories: ['news'] });
|
||||
const draft = makePost({ id: 'draft-1', status: 'draft', categories: ['news'] });
|
||||
const draftSnapshot = makePost({ id: 'draft-1', status: 'published', categories: ['news'] });
|
||||
|
||||
const result = await loadPublishedGenerationSets(makeEngine([published, draft], { 'draft-1': draftSnapshot }), []);
|
||||
|
||||
expect(result.publishedPosts).toHaveLength(2);
|
||||
expect(result.publishedListPosts).toHaveLength(2);
|
||||
expect(result.publishedPosts.map((post) => post.id).sort()).toEqual(['draft-1', 'pub-1']);
|
||||
});
|
||||
|
||||
it('excludes list-disabled categories only from list snapshot set', async () => {
|
||||
const article = makePost({ id: 'article', status: 'published', categories: ['article'] });
|
||||
const page = makePost({ id: 'page', status: 'published', categories: ['page'] });
|
||||
|
||||
const result = await loadPublishedGenerationSets(makeEngine([article, page]), ['page']);
|
||||
|
||||
expect(result.publishedPosts.map((post) => post.id).sort()).toEqual(['article', 'page']);
|
||||
expect(result.publishedListPosts.map((post) => post.id)).toEqual(['article']);
|
||||
});
|
||||
});
|
||||
138
tests/engine/GenerationSitemapFeedService.test.ts
Normal file
138
tests/engine/GenerationSitemapFeedService.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
import {
|
||||
buildSitemapAndFeeds,
|
||||
type GenerationPostIndexLike,
|
||||
} from '../../src/main/engine/GenerationSitemapFeedService';
|
||||
|
||||
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
|
||||
const updatedAt = overrides.updatedAt ?? createdAt;
|
||||
const title = overrides.title ?? 'Title';
|
||||
|
||||
return {
|
||||
id: overrides.id ?? 'post-1',
|
||||
projectId: overrides.projectId ?? 'default',
|
||||
title,
|
||||
slug: overrides.slug ?? 'title',
|
||||
excerpt: overrides.excerpt,
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildIndex(posts: PostData[]): GenerationPostIndexLike {
|
||||
const postsByCategory = new Map<string, PostData[]>();
|
||||
const postsByTag = new Map<string, PostData[]>();
|
||||
const postsByYear = new Map<number, PostData[]>();
|
||||
const postsByYearMonth = new Map<string, PostData[]>();
|
||||
const postsByYearMonthDay = new Map<string, PostData[]>();
|
||||
|
||||
for (const post of posts) {
|
||||
const categories = Array.isArray(post.categories) ? post.categories : [];
|
||||
for (const category of categories) {
|
||||
const existing = postsByCategory.get(category) ?? [];
|
||||
existing.push(post);
|
||||
postsByCategory.set(category, existing);
|
||||
}
|
||||
|
||||
const tags = Array.isArray(post.tags) ? post.tags : [];
|
||||
for (const tag of tags) {
|
||||
const existing = postsByTag.get(tag) ?? [];
|
||||
existing.push(post);
|
||||
postsByTag.set(tag, existing);
|
||||
}
|
||||
|
||||
const createdAt = post.createdAt;
|
||||
const year = createdAt.getFullYear();
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||
const yearMonth = `${year}/${month}`;
|
||||
const yearMonthDay = `${year}/${month}/${day}`;
|
||||
|
||||
postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]);
|
||||
postsByYearMonth.set(yearMonth, [...(postsByYearMonth.get(yearMonth) ?? []), post]);
|
||||
postsByYearMonthDay.set(yearMonthDay, [...(postsByYearMonthDay.get(yearMonthDay) ?? []), post]);
|
||||
}
|
||||
|
||||
return {
|
||||
postsByCategory,
|
||||
postsByTag,
|
||||
postsByYear,
|
||||
postsByYearMonth,
|
||||
postsByYearMonthDay,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GenerationSitemapFeedService', () => {
|
||||
it('builds canonical sitemap urls and paginated archive routes', () => {
|
||||
const publishedPosts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'news-1',
|
||||
createdAt: new Date('2025-01-15T10:00:00.000Z'),
|
||||
categories: ['news'],
|
||||
tags: ['tag-a'],
|
||||
}),
|
||||
makePost({
|
||||
id: '2',
|
||||
slug: 'news-2',
|
||||
createdAt: new Date('2025-01-14T10:00:00.000Z'),
|
||||
categories: ['news'],
|
||||
tags: ['tag-a'],
|
||||
}),
|
||||
makePost({
|
||||
id: '3',
|
||||
slug: 'about',
|
||||
createdAt: new Date('2025-01-13T10:00:00.000Z'),
|
||||
categories: ['page'],
|
||||
}),
|
||||
];
|
||||
|
||||
const publishedListPosts = publishedPosts.filter((post) => !(post.categories || []).includes('page'));
|
||||
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Test Blog',
|
||||
projectDescription: 'Desc',
|
||||
maxPostsPerPage: 1,
|
||||
publishedPosts,
|
||||
publishedListPosts,
|
||||
postIndex: buildIndex(publishedListPosts),
|
||||
includeFeeds: true,
|
||||
});
|
||||
|
||||
expect(result.sitemapXml).toContain('<loc>https://example.com/</loc>');
|
||||
expect(result.sitemapXml).toContain('<loc>https://example.com/page/2/</loc>');
|
||||
expect(result.sitemapXml).toContain('<loc>https://example.com/2025/01/15/news-1/</loc>');
|
||||
expect(result.sitemapXml).toContain('<loc>https://example.com/category/news/page/2/</loc>');
|
||||
expect(result.sitemapXml).toContain('<loc>https://example.com/tag/tag-a/page/2/</loc>');
|
||||
expect(result.sitemapXml).toContain('<loc>https://example.com/about/</loc>');
|
||||
expect(result.rssXml).toContain('<rss version="2.0"');
|
||||
expect(result.atomXml).toContain('<feed xmlns="http://www.w3.org/2005/Atom">');
|
||||
});
|
||||
|
||||
it('can skip feed xml generation for sitemap-only flows', () => {
|
||||
const publishedPosts = [makePost({ id: '1', slug: 'post-1', categories: ['news'], tags: ['t1'] })];
|
||||
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Test Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts,
|
||||
publishedListPosts: publishedPosts,
|
||||
postIndex: buildIndex(publishedPosts),
|
||||
includeFeeds: false,
|
||||
});
|
||||
|
||||
expect(result.sitemapXml).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
|
||||
expect(result.rssXml).toBe('');
|
||||
expect(result.atomXml).toBe('');
|
||||
});
|
||||
});
|
||||
133
tests/engine/SharedSnapshotService.test.ts
Normal file
133
tests/engine/SharedSnapshotService.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { PostData, PostFilter } from '../../src/main/engine/PostEngine';
|
||||
import {
|
||||
findSinglePostBySlug,
|
||||
loadPostsForDayPage,
|
||||
loadPublishedSnapshotsPage,
|
||||
type SharedSnapshotPostEngine,
|
||||
} from '../../src/main/engine/SharedSnapshotService';
|
||||
|
||||
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
|
||||
const updatedAt = overrides.updatedAt ?? createdAt;
|
||||
const title = overrides.title ?? 'Title';
|
||||
|
||||
return {
|
||||
id: overrides.id ?? 'post-1',
|
||||
projectId: overrides.projectId ?? 'default',
|
||||
title,
|
||||
slug: overrides.slug ?? 'title',
|
||||
excerpt: overrides.excerpt,
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData | null> = {}): SharedSnapshotPostEngine {
|
||||
const byId = new Map(posts.map((post) => [post.id, post]));
|
||||
|
||||
return {
|
||||
async getPost(id: string): Promise<PostData | null> {
|
||||
return byId.get(id) ?? null;
|
||||
},
|
||||
async getPublishedVersion(id: string): Promise<PostData | null> {
|
||||
return snapshotsById[id] ?? null;
|
||||
},
|
||||
async getPostsFiltered(filter: PostFilter): Promise<PostData[]> {
|
||||
let result = posts.filter((post) => post.status === (filter.status ?? post.status));
|
||||
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
result = result.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag)));
|
||||
}
|
||||
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
if ((filter as any).excludeCategories && (filter as any).excludeCategories.length > 0) {
|
||||
result = result.filter((post) => !(filter as any).excludeCategories.some((category: string) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
if (filter.year !== undefined) {
|
||||
result = result.filter((post) => post.createdAt.getFullYear() === filter.year);
|
||||
}
|
||||
|
||||
if (filter.month !== undefined && filter.year !== undefined) {
|
||||
result = result.filter((post) => post.createdAt.getMonth() === filter.month);
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
result = result.filter((post) => post.createdAt >= filter.startDate!);
|
||||
}
|
||||
|
||||
if (filter.endDate) {
|
||||
result = result.filter((post) => post.createdAt <= filter.endDate!);
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('SharedSnapshotService', () => {
|
||||
it('loads published snapshots merged from published and draft rows', async () => {
|
||||
const published = makePost({ id: 'p1', slug: 'published-1', status: 'published' });
|
||||
const draft = makePost({ id: 'd1', slug: 'draft-1', status: 'draft' });
|
||||
const draftPublishedSnapshot = makePost({ id: 'd1', slug: 'draft-1', status: 'published' });
|
||||
|
||||
const engine = makeEngine([published, draft], { d1: draftPublishedSnapshot });
|
||||
|
||||
const result = await loadPublishedSnapshotsPage(engine, { status: 'published' }, { maxPostsPerPage: 50, page: 1 });
|
||||
|
||||
expect(result.totalPosts).toBe(2);
|
||||
expect(result.posts.map((post) => post.id).sort()).toEqual(['d1', 'p1']);
|
||||
});
|
||||
|
||||
it('loads day page strictly for given day', async () => {
|
||||
const dayA = makePost({ id: 'a', slug: 'a', createdAt: new Date('2025-01-15T10:00:00.000Z') });
|
||||
const dayB = makePost({ id: 'b', slug: 'b', createdAt: new Date('2025-01-16T10:00:00.000Z') });
|
||||
const engine = makeEngine([dayA, dayB]);
|
||||
|
||||
const result = await loadPostsForDayPage(engine, 2025, 1, 15, { maxPostsPerPage: 50, page: 1 });
|
||||
|
||||
expect(result.totalPosts).toBe(1);
|
||||
expect(result.posts).toHaveLength(1);
|
||||
expect(result.posts[0]?.id).toBe('a');
|
||||
});
|
||||
|
||||
it('prefers matching draft post when draft preview options are provided', async () => {
|
||||
const draft = makePost({ id: 'draft-1', slug: 'my-post', status: 'draft', createdAt: new Date('2025-03-21T10:00:00.000Z') });
|
||||
const published = makePost({ id: 'pub-1', slug: 'my-post', status: 'published', createdAt: new Date('2025-03-20T10:00:00.000Z') });
|
||||
const engine = makeEngine([published, draft]);
|
||||
|
||||
const result = await findSinglePostBySlug(
|
||||
engine,
|
||||
'my-post',
|
||||
{ useDraftContent: true, draftPostId: 'draft-1' },
|
||||
{ year: 2025, month: 2, day: 21 },
|
||||
);
|
||||
|
||||
expect(result?.id).toBe('draft-1');
|
||||
});
|
||||
|
||||
it('uses findPublishedBySlug shortcut when present', async () => {
|
||||
const post = makePost({ id: 'x1', slug: 'shortcut', status: 'published' });
|
||||
const engine = makeEngine([post]);
|
||||
const findPublishedBySlug = vi.fn(async () => post);
|
||||
const engineWithShortcut: SharedSnapshotPostEngine = {
|
||||
...engine,
|
||||
findPublishedBySlug,
|
||||
};
|
||||
|
||||
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 0, day: 2 });
|
||||
|
||||
expect(result?.id).toBe('x1');
|
||||
expect(findPublishedBySlug).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
66
tests/engine/SiteValidationDiffService.test.ts
Normal file
66
tests/engine/SiteValidationDiffService.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareSitemapToHtml } from '../../src/main/engine/SiteValidationDiffService';
|
||||
|
||||
function makeTempName(): string {
|
||||
return `bds-site-validation-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
describe('SiteValidationDiffService', () => {
|
||||
it('computes missing and extra URL paths from sitemap xml and html tree', async () => {
|
||||
const tempRoot = path.join('/tmp', makeTempName());
|
||||
const htmlDir = path.join(tempRoot, 'html');
|
||||
|
||||
await mkdir(path.join(htmlDir, 'category', 'news', 'page', '2'), { recursive: true });
|
||||
await mkdir(path.join(htmlDir, 'stale'), { recursive: true });
|
||||
await writeFile(path.join(htmlDir, 'index.html'), '<html>root</html>', 'utf-8');
|
||||
await writeFile(path.join(htmlDir, 'category', 'news', 'index.html'), '<html>news</html>', 'utf-8');
|
||||
await writeFile(path.join(htmlDir, 'category', 'news', 'page', '2', 'index.html'), '<html>news p2</html>', 'utf-8');
|
||||
await writeFile(path.join(htmlDir, 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
||||
|
||||
const sitemapXml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
' <url><loc>https://example.com/</loc></url>',
|
||||
' <url><loc>https://example.com/category/news/</loc></url>',
|
||||
' <url><loc>https://example.com/category/news/page/2/</loc></url>',
|
||||
' <url><loc>https://example.com/tag/dev/</loc></url>',
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = await compareSitemapToHtml({
|
||||
sitemapXml,
|
||||
baseUrl: 'https://example.com',
|
||||
htmlDir,
|
||||
});
|
||||
|
||||
expect(result.missingUrlPaths).toEqual(['/tag/dev']);
|
||||
expect(result.extraUrlPaths).toEqual(['/stale']);
|
||||
expect(result.expectedUrlCount).toBe(4);
|
||||
expect(result.existingHtmlUrlCount).toBe(4);
|
||||
});
|
||||
|
||||
it('normalizes base path urls and tolerates missing html dir', async () => {
|
||||
const sitemapXml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
' <url><loc>https://example.com/blog/</loc></url>',
|
||||
' <url><loc>https://example.com/blog/page/2/</loc></url>',
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = await compareSitemapToHtml({
|
||||
sitemapXml,
|
||||
baseUrl: 'https://example.com/blog',
|
||||
htmlDir: path.join('/tmp', makeTempName(), 'missing-html-dir'),
|
||||
});
|
||||
|
||||
expect(result.missingUrlPaths).toEqual(['/', '/page/2']);
|
||||
expect(result.extraUrlPaths).toEqual([]);
|
||||
expect(result.expectedUrlCount).toBe(2);
|
||||
expect(result.existingHtmlUrlCount).toBe(0);
|
||||
});
|
||||
});
|
||||
94
tests/engine/ValidationApplyPlannerService.test.ts
Normal file
94
tests/engine/ValidationApplyPlannerService.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
import {
|
||||
buildTargetedValidationPlan,
|
||||
planMissingValidationPaths,
|
||||
} from '../../src/main/engine/ValidationApplyPlannerService';
|
||||
|
||||
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
const createdAt = overrides.createdAt ?? new Date('2025-01-15T10:00:00.000Z');
|
||||
const updatedAt = overrides.updatedAt ?? createdAt;
|
||||
|
||||
return {
|
||||
id: overrides.id ?? 'post-1',
|
||||
projectId: overrides.projectId ?? 'default',
|
||||
title: overrides.title ?? 'Title',
|
||||
slug: overrides.slug ?? 'title',
|
||||
excerpt: overrides.excerpt,
|
||||
content: overrides.content ?? 'Body',
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ValidationApplyPlannerService', () => {
|
||||
it('classifies missing paths into route request groups', () => {
|
||||
const plan = planMissingValidationPaths([
|
||||
'/',
|
||||
'/page/2',
|
||||
'/category/news/page/2',
|
||||
'/tag/dev%20log',
|
||||
'/2025/01/15/my%20post',
|
||||
'/2025/page/2',
|
||||
'/2025/01',
|
||||
'/2025/01/15',
|
||||
'/about',
|
||||
]);
|
||||
|
||||
expect(plan.requiresFallbackSectionRender).toBe(false);
|
||||
expect(plan.requestRootRoutes).toBe(true);
|
||||
expect(Array.from(plan.requestedCategories)).toEqual(['news']);
|
||||
expect(Array.from(plan.requestedTags)).toEqual(['dev log']);
|
||||
expect(plan.requestedPostRoutes).toEqual([
|
||||
{ year: 2025, month: 1, day: 15, slug: 'my post' },
|
||||
]);
|
||||
expect(Array.from(plan.requestedYears)).toContain(2025);
|
||||
expect(Array.from(plan.requestedYearMonths)).toContain('2025/01');
|
||||
expect(Array.from(plan.requestedYearMonthDays)).toContain('2025/01/15');
|
||||
expect(Array.from(plan.requestedPageSlugs)).toEqual(['about']);
|
||||
});
|
||||
|
||||
it('expands targeted rerender plan with single-route lineage and available archives', () => {
|
||||
const publishedPost = makePost({
|
||||
id: 'p1',
|
||||
slug: 'post-one',
|
||||
categories: ['news'],
|
||||
tags: ['tag-1'],
|
||||
createdAt: new Date('2025-01-15T10:00:00.000Z'),
|
||||
});
|
||||
const pagePost = makePost({
|
||||
id: 'p2',
|
||||
slug: 'about',
|
||||
categories: ['page'],
|
||||
tags: [],
|
||||
createdAt: new Date('2025-01-10T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const initialPlan = planMissingValidationPaths(['/2025/01/15/post-one', '/2025', '/about', '/category/missing']);
|
||||
|
||||
const targeted = buildTargetedValidationPlan({
|
||||
initialPlan,
|
||||
publishedPosts: [publishedPost, pagePost],
|
||||
allCategories: new Set(['news', 'page']),
|
||||
allTags: new Set(['tag-1']),
|
||||
availableYearMonths: ['2025/01', '2025/02'],
|
||||
availableYearMonthDays: ['2025/01/15', '2025/02/20'],
|
||||
});
|
||||
|
||||
expect(targeted.requestedPostIds.has('p1')).toBe(true);
|
||||
expect(targeted.requestedCategorySet.has('news')).toBe(true);
|
||||
expect(targeted.requestedCategorySet.has('missing')).toBe(false);
|
||||
expect(targeted.requestedTagSet.has('tag-1')).toBe(true);
|
||||
expect(targeted.requestedYears.has(2025)).toBe(true);
|
||||
expect(targeted.requestedYearMonths.has('2025/01')).toBe(true);
|
||||
expect(targeted.requestedYearMonths.has('2025/02')).toBe(true);
|
||||
expect(targeted.requestedYearMonthDays.has('2025/01/15')).toBe(true);
|
||||
expect(targeted.requestedYearMonthDays.has('2025/02/20')).toBe(true);
|
||||
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user