chore: refactorings and code sharing

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

View File

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

View File

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

View File

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

View File

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

View File

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