Feature/worker threads generation (#43)

* Add worker threads architecture plan for blog generation

* fix: tries to optimize rendering, still slow

* feat: moved site rendering into web worker

* fix: calendar grabs from central data source for calendar

* fix: feeds now use blog language content and not canonical content

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-09 22:49:25 +01:00
committed by GitHub
parent b855d61524
commit 4f9be93c6d
42 changed files with 3617 additions and 346 deletions

View File

@@ -57,6 +57,7 @@ vi.mock('../../src/main/database/generatedFileHashStore', () => ({
getGeneratedFileHash: getGeneratedFileHashMock,
getGeneratedFileHashRecord: getGeneratedFileHashRecordMock,
setGeneratedFileHash: setGeneratedFileHashMock,
getAllGeneratedFileHashes: vi.fn(async () => new Map<string, string>()),
}));
vi.mock('../../src/main/database', () => ({
@@ -76,7 +77,7 @@ vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
getPostTranslation: vi.fn(async () => null),
getPostTranslations: vi.fn(async () => []),
setProjectContext: vi.fn(),
};
} as Record<string, any>;
return {
...actual,
getPostEngine: vi.fn(() => mockPostEngine),
@@ -211,6 +212,7 @@ describe('BlogGenerationEngine', () => {
options?: Partial<{
maxPostsPerPage: number;
language: string;
blogLanguages: string[];
pageTitle: string;
picoTheme: string;
categorySettings: Record<string, { renderInLists: boolean; showTitle: boolean }>;
@@ -229,6 +231,7 @@ describe('BlogGenerationEngine', () => {
baseUrl: 'https://example.com',
maxPostsPerPage: options?.maxPostsPerPage,
language: options?.language,
blogLanguages: options?.blogLanguages,
pageTitle: options?.pageTitle,
picoTheme: options?.picoTheme as any,
categorySettings: options?.categorySettings,
@@ -2148,6 +2151,154 @@ describe('BlogGenerationEngine', () => {
expect(result.postCount).toBe(0);
});
it('language subtree list pages show translated title and excerpt, not canonical language', async () => {
const posts = [
makePost({
id: 'de-post-1',
slug: 'german-post',
title: 'Deutscher Titel',
excerpt: 'Deutscher Auszug',
content: '# Deutscher Inhalt',
language: 'de',
categories: ['tech'],
createdAt: new Date('2025-06-10T10:00:00Z'),
availableLanguages: ['de', 'en'],
}),
];
const translationMap = new Map<string, PostTranslationData[]>();
translationMap.set('de-post-1', [{
id: 'en-trans-1',
projectId: 'test',
translationFor: 'de-post-1',
language: 'en',
title: 'English Title',
excerpt: 'English excerpt',
content: '# English Content',
status: 'published',
createdAt: new Date('2025-06-10T10:00:00Z'),
updatedAt: new Date('2025-06-10T10:00:00Z'),
publishedAt: new Date('2025-06-10T10:00:00Z'),
filePath: '',
}]);
mockPostEngine.getPublishedTranslationsForRoutePosts = vi.fn().mockResolvedValue(translationMap);
await generate(posts, { language: 'de', blogLanguages: ['de', 'en'] });
// /en/ subtree list page should show English title and excerpt
const enIndex = await readFile(path.join(tempDir, 'html', 'en', 'index.html'), 'utf-8');
expect(enIndex).toContain('English Title');
expect(enIndex).toContain('English excerpt');
expect(enIndex).not.toContain('Deutscher Titel');
expect(enIndex).not.toContain('Deutscher Auszug');
// Main blog list page should still show German
const deIndex = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
expect(deIndex).toContain('Deutscher Titel');
expect(deIndex).not.toContain('English Title');
});
it('main blog list pages show translated content when canonical language differs from project language', async () => {
const posts = [
makePost({
id: 'en-post-1',
slug: 'english-post',
title: 'English Title',
excerpt: 'English excerpt',
content: '# English Content',
language: 'en',
categories: ['tech'],
createdAt: new Date('2025-06-10T10:00:00Z'),
availableLanguages: ['en', 'de'],
}),
];
const translationMap = new Map<string, PostTranslationData[]>();
translationMap.set('en-post-1', [{
id: 'de-trans-1',
projectId: 'test',
translationFor: 'en-post-1',
language: 'de',
title: 'Deutscher Titel',
excerpt: 'Deutscher Auszug',
content: '# Deutscher Inhalt',
status: 'published',
createdAt: new Date('2025-06-10T10:00:00Z'),
updatedAt: new Date('2025-06-10T10:00:00Z'),
publishedAt: new Date('2025-06-10T10:00:00Z'),
filePath: '',
}]);
mockPostEngine.getPublishedTranslationsForRoutePosts = vi.fn().mockResolvedValue(translationMap);
await generate(posts, { language: 'de', blogLanguages: ['de', 'en'] });
// Main blog (de) should show German translated title, not English canonical
const deIndex = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
expect(deIndex).toContain('Deutscher Titel');
expect(deIndex).not.toContain('English Title');
// /en/ subtree should show English canonical title
const enIndex = await readFile(path.join(tempDir, 'html', 'en', 'index.html'), 'utf-8');
expect(enIndex).toContain('English Title');
expect(enIndex).not.toContain('Deutscher Titel');
});
it('language subtree RSS and Atom feeds use translated titles and content', async () => {
const posts = [
makePost({
id: 'de-post-1',
slug: 'german-post',
title: 'Deutscher Titel',
content: '# Deutscher Inhalt\n\nDeutscher Body Text',
language: 'de',
categories: ['tech'],
createdAt: new Date('2025-06-10T10:00:00Z'),
availableLanguages: ['de', 'en'],
}),
];
const translationFilePath = path.join(tempDir, 'posts', 'german-post.en.md');
await mkdir(path.join(tempDir, 'posts'), { recursive: true });
await writeFile(translationFilePath, '---\ntranslationFor: de-post-1\nlanguage: en\ntitle: English Title\n---\n# English Content\n\nEnglish Body Text');
const translationMap = new Map<string, PostTranslationData[]>();
translationMap.set('de-post-1', [{
id: 'en-trans-1',
projectId: 'test',
translationFor: 'de-post-1',
language: 'en',
title: 'English Title',
content: '# English Content\n\nEnglish Body Text',
status: 'published',
createdAt: new Date('2025-06-10T10:00:00Z'),
updatedAt: new Date('2025-06-10T10:00:00Z'),
publishedAt: new Date('2025-06-10T10:00:00Z'),
filePath: translationFilePath,
}]);
mockPostEngine.getPublishedTranslationsForRoutePosts = vi.fn().mockResolvedValue(translationMap);
await generate(posts, { language: 'de', blogLanguages: ['de', 'en'] });
// /en/ RSS feed should use English translated title and content
const enRss = await readFile(path.join(tempDir, 'html', 'en', 'rss.xml'), 'utf-8');
expect(enRss).toContain('English Title');
expect(enRss).not.toContain('Deutscher Titel');
expect(enRss).toContain('English Body Text');
expect(enRss).not.toContain('Deutscher Body Text');
// /en/ Atom feed should use English translated title and content
const enAtom = await readFile(path.join(tempDir, 'html', 'en', 'atom.xml'), 'utf-8');
expect(enAtom).toContain('English Title');
expect(enAtom).not.toContain('Deutscher Titel');
expect(enAtom).toContain('English Body Text');
expect(enAtom).not.toContain('Deutscher Body Text');
// Root RSS should keep German canonical content
const deRss = await readFile(path.join(tempDir, 'html', 'rss.xml'), 'utf-8');
expect(deRss).toContain('Deutscher Titel');
expect(deRss).not.toContain('English Title');
});
it('generates pagination links in list pages', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 4; i++) {

View File

@@ -0,0 +1,471 @@
import { describe, expect, it, vi } from 'vitest';
import {
createDataBackedPostEngine,
createDataBackedMediaEngine,
createDataBackedPostMediaEngine,
} from '../../src/main/engine/DataBackedEngines';
import type { PostData } from '../../src/main/engine/PostEngine';
function makePost(overrides: Partial<PostData> & { id: string; slug: string }): PostData {
return {
projectId: 'proj-1',
title: overrides.slug,
excerpt: '',
content: `content of ${overrides.slug}`,
status: 'published',
createdAt: new Date('2025-06-15T12:00:00Z'),
updatedAt: new Date('2025-06-15T12:00:00Z'),
tags: [],
categories: [],
availableLanguages: [],
...overrides,
};
}
describe('DataBackedPostEngine', () => {
const posts: PostData[] = [
makePost({ id: '1', slug: 'alpha', tags: ['js', 'node'], categories: ['article'], createdAt: new Date('2025-01-10T00:00:00Z') }),
makePost({ id: '2', slug: 'beta', tags: ['python'], categories: ['aside'], createdAt: new Date('2025-02-15T00:00:00Z') }),
makePost({ id: '3', slug: 'gamma', tags: ['js'], categories: ['page'], createdAt: new Date('2025-03-20T00:00:00Z') }),
makePost({ id: '4', slug: 'delta', tags: ['rust'], categories: ['article'], createdAt: new Date('2024-12-01T00:00:00Z') }),
];
const backlinksMap = new Map([
['1', [{ id: '2', title: 'beta', slug: 'beta' }]],
]);
const engine = createDataBackedPostEngine({ allPosts: posts, backlinksMap });
describe('getPostsFiltered', () => {
it('returns all posts when no filter applied', async () => {
const result = await engine.getPostsFiltered({});
expect(result).toHaveLength(4);
});
it('filters by status', async () => {
const result = await engine.getPostsFiltered({ status: 'published' });
expect(result).toHaveLength(4);
const drafts = await engine.getPostsFiltered({ status: 'draft' });
expect(drafts).toHaveLength(0);
});
it('filters by excludeCategories', async () => {
const result = await engine.getPostsFiltered({ excludeCategories: ['page'] });
expect(result).toHaveLength(3);
expect(result.every((p) => !p.categories.includes('page'))).toBe(true);
});
it('filters by categories', async () => {
const result = await engine.getPostsFiltered({ categories: ['article'] });
expect(result).toHaveLength(2);
});
it('filters by tags (all must match)', async () => {
const result = await engine.getPostsFiltered({ tags: ['js'] });
expect(result).toHaveLength(2);
const both = await engine.getPostsFiltered({ tags: ['js', 'node'] });
expect(both).toHaveLength(1);
expect(both[0].slug).toBe('alpha');
});
it('filters by year', async () => {
const result = await engine.getPostsFiltered({ year: 2025 });
expect(result).toHaveLength(3);
});
it('filters by year and month', async () => {
const result = await engine.getPostsFiltered({ year: 2025, month: 2 });
expect(result).toHaveLength(1);
expect(result[0].slug).toBe('beta');
});
it('filters by date range', async () => {
const result = await engine.getPostsFiltered({
startDate: new Date('2025-01-01T00:00:00Z'),
endDate: new Date('2025-02-28T23:59:59Z'),
});
expect(result).toHaveLength(2);
});
it('returns sorted by createdAt descending', async () => {
const result = await engine.getPostsFiltered({});
const dates = result.map((p) => p.createdAt.getTime());
for (let i = 1; i < dates.length; i++) {
expect(dates[i]).toBeLessThanOrEqual(dates[i - 1]);
}
});
it('excludes translation variants from filtered results', async () => {
const canonical = makePost({ id: 'c1', slug: 'hello', content: 'Hello content', language: 'de' });
const variant = makePost({
id: 'v1',
slug: 'hello.en',
content: 'Hello EN content',
language: 'en',
translationSourceSlug: 'hello',
} as any);
const eng = createDataBackedPostEngine({ allPosts: [canonical, variant] });
const result = await eng.getPostsFiltered({ status: 'published' });
expect(result).toHaveLength(1);
expect(result[0].slug).toBe('hello');
});
it('translation variants remain accessible via findPublishedBySlug', async () => {
const canonical = makePost({ id: 'c1', slug: 'hello', content: 'Hello content', language: 'de' });
const variant = makePost({
id: 'v1',
slug: 'hello.en',
content: 'Hello EN content',
language: 'en',
translationSourceSlug: 'hello',
} as any);
const eng = createDataBackedPostEngine({ allPosts: [canonical, variant] });
const found = await eng.findPublishedBySlug('hello.en');
expect(found).not.toBeNull();
expect(found!.id).toBe('v1');
});
it('includes resolved posts (slug === translationSourceSlug) in filtered results', async () => {
// A resolved post is a canonical post with its title/excerpt replaced by a translation.
// Its slug remains the same as translationSourceSlug (e.g., both are "hello").
const resolved = makePost({
id: 'r1',
slug: 'hello',
title: 'Hallo (übersetzt)',
language: 'en',
translationSourceSlug: 'hello',
} as any);
const unresolved = makePost({
id: 'r2',
slug: 'world',
title: 'Welt',
language: 'de',
});
const eng = createDataBackedPostEngine({ allPosts: [resolved, unresolved] });
const result = await eng.getPostsFiltered({ status: 'published' });
expect(result).toHaveLength(2);
expect(result.map((p) => p.slug).sort()).toEqual(['hello', 'world']);
});
it('lazy-loads translation content for resolved posts returned by getPostsFiltered', async () => {
const resolved = makePost({
id: 'lz-1',
slug: 'lazy-post',
title: 'Translated Title',
content: '',
language: 'en',
translationSourceSlug: 'lazy-post',
translationFilePath: '/data/posts/2025/06/lazy-post.en.md',
} as any);
const readTranslationSpy = vi.spyOn(await import('../../src/main/engine/postTranslationFileUtils'), 'readPostTranslationFile');
readTranslationSpy.mockResolvedValueOnce({
translationFor: 'lz-1',
language: 'en',
title: 'Translated Title',
content: 'Lazy loaded translation content!',
});
const eng = createDataBackedPostEngine({
allPosts: [resolved],
postFilePaths: new Map(),
});
const result = await eng.getPostsFiltered({ status: 'published' });
expect(result).toHaveLength(1);
expect(result[0].content).toBe('Lazy loaded translation content!');
expect(readTranslationSpy).toHaveBeenCalledWith('/data/posts/2025/06/lazy-post.en.md');
readTranslationSpy.mockRestore();
});
});
describe('lookup methods', () => {
it('getPublishedVersion returns by id', async () => {
const post = await engine.getPublishedVersion('2');
expect(post?.slug).toBe('beta');
});
it('getPublishedVersion returns null for unknown id', async () => {
expect(await engine.getPublishedVersion('unknown')).toBeNull();
});
it('getPost returns by id', async () => {
const post = await engine.getPost('3');
expect(post?.slug).toBe('gamma');
});
it('hasPublishedVersion checks existence', async () => {
expect(await engine.hasPublishedVersion('1')).toBe(true);
expect(await engine.hasPublishedVersion('nope')).toBe(false);
});
it('findPublishedBySlug returns first match without date filter', async () => {
const post = await engine.findPublishedBySlug('alpha');
expect(post?.id).toBe('1');
});
it('findPublishedBySlug returns null for unknown slug', async () => {
expect(await engine.findPublishedBySlug('nope')).toBeNull();
});
it('findPublishedBySlug applies date filter', async () => {
const match = await engine.findPublishedBySlug('alpha', { year: 2025, month: 1 });
expect(match?.id).toBe('1');
const noMatch = await engine.findPublishedBySlug('alpha', { year: 2024, month: 1 });
expect(noMatch).toBeNull();
});
});
describe('backlinks', () => {
it('getLinkedBy returns backlinks for a post', async () => {
const links = await engine.getLinkedBy('1');
expect(links).toEqual([{ id: '2', title: 'beta', slug: 'beta' }]);
});
it('getLinkedBy returns empty for unlinked post', async () => {
const links = await engine.getLinkedBy('3');
expect(links).toEqual([]);
});
it('getAllBacklinks returns the full map', async () => {
const map = await engine.getAllBacklinks();
expect(map.size).toBe(1);
expect(map.get('1')).toHaveLength(1);
});
});
describe('translations (stubs)', () => {
it('getPostTranslation returns null', async () => {
expect(await engine.getPostTranslation('1', 'fr')).toBeNull();
});
it('getPostTranslations returns empty array', async () => {
expect(await engine.getPostTranslations('1')).toEqual([]);
});
});
describe('setProjectContext is a no-op', () => {
it('does not throw', () => {
expect(() => engine.setProjectContext('proj-1', '/data')).not.toThrow();
});
});
describe('getPublishedVersion with lazy FS content loading', () => {
it('reads content from filesystem when post has no content and filePath is available', async () => {
const emptyContentPost = makePost({ id: 'fs-1', slug: 'fs-post', content: '' });
const postFilePaths = new Map([['fs-1', '/data/posts/2025/06/fs-post.md']]);
const { readPostFile } = await import('../../src/main/engine/postFileUtils');
const readPostFileSpy = vi.spyOn(await import('../../src/main/engine/postFileUtils'), 'readPostFile');
readPostFileSpy.mockResolvedValueOnce({
id: 'fs-1',
title: 'FS Post',
slug: 'fs-post',
content: 'Content loaded from filesystem!',
status: 'published',
createdAt: new Date('2025-06-15'),
updatedAt: new Date('2025-06-15'),
tags: [],
categories: [],
});
const fsEngine = createDataBackedPostEngine({
allPosts: [emptyContentPost],
postFilePaths,
});
const result = await fsEngine.getPublishedVersion('fs-1');
expect(result).not.toBeNull();
expect(result!.content).toBe('Content loaded from filesystem!');
expect(readPostFileSpy).toHaveBeenCalledWith('/data/posts/2025/06/fs-post.md');
readPostFileSpy.mockRestore();
});
it('does not read from FS when post already has content', async () => {
const postWithContent = makePost({ id: 'has-c', slug: 'has-content', content: 'Already here' });
const postFilePaths = new Map([['has-c', '/data/posts/2025/06/has-content.md']]);
const readPostFileSpy = vi.spyOn(await import('../../src/main/engine/postFileUtils'), 'readPostFile');
const fsEngine = createDataBackedPostEngine({
allPosts: [postWithContent],
postFilePaths,
});
const result = await fsEngine.getPublishedVersion('has-c');
expect(result!.content).toBe('Already here');
expect(readPostFileSpy).not.toHaveBeenCalled();
readPostFileSpy.mockRestore();
});
it('reads translation content from translationFilePath', async () => {
const translationPost = makePost({ id: 'tr-1', slug: 'post.fr', content: '' });
(translationPost as any).translationFilePath = '/data/posts/2025/06/post.fr.md';
const readTranslationSpy = vi.spyOn(await import('../../src/main/engine/postTranslationFileUtils'), 'readPostTranslationFile');
readTranslationSpy.mockResolvedValueOnce({
translationFor: 'original-id',
language: 'fr',
title: 'Post FR',
content: 'Contenu fran\u00e7ais!',
});
const fsEngine = createDataBackedPostEngine({
allPosts: [translationPost],
postFilePaths: new Map(),
});
const result = await fsEngine.getPublishedVersion('tr-1');
expect(result!.content).toBe('Contenu fran\u00e7ais!');
expect(readTranslationSpy).toHaveBeenCalledWith('/data/posts/2025/06/post.fr.md');
readTranslationSpy.mockRestore();
});
});
describe('getPost with lazy FS content loading', () => {
it('reads content from filesystem when post has no content', async () => {
const emptyContentPost = makePost({ id: 'gp-1', slug: 'gp-post', content: '' });
const postFilePaths = new Map([['gp-1', '/data/posts/2025/06/gp-post.md']]);
const readPostFileSpy = vi.spyOn(await import('../../src/main/engine/postFileUtils'), 'readPostFile');
readPostFileSpy.mockResolvedValueOnce({
id: 'gp-1',
title: 'GP Post',
slug: 'gp-post',
content: 'Content via getPost!',
status: 'published',
createdAt: new Date('2025-06-15'),
updatedAt: new Date('2025-06-15'),
tags: [],
categories: [],
});
const fsEngine = createDataBackedPostEngine({
allPosts: [emptyContentPost],
postFilePaths,
});
const result = await fsEngine.getPost('gp-1');
expect(result).not.toBeNull();
expect(result!.content).toBe('Content via getPost!');
expect(readPostFileSpy).toHaveBeenCalledWith('/data/posts/2025/06/gp-post.md');
readPostFileSpy.mockRestore();
});
});
});
describe('DataBackedMediaEngine', () => {
const mediaItems: import('../../src/main/engine/MediaEngine').MediaData[] = [
{
id: 'm1', filename: 'photo.jpg', originalName: 'my-photo.jpg',
mimeType: 'image/jpeg', size: 12345, width: 800, height: 600,
title: 'Beach', alt: 'Beach photo', caption: 'A sunny beach',
author: 'Alice', language: 'en',
createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
tags: ['nature', 'beach'], linkedPostIds: ['p1', 'p2'], availableLanguages: ['en', 'de'],
},
{
id: 'm2', filename: 'doc.pdf', originalName: 'document.pdf',
mimeType: 'application/pdf', size: 99999,
createdAt: new Date('2025-02-01'), updatedAt: new Date('2025-02-01'),
tags: [], availableLanguages: [],
},
];
const engine = createDataBackedMediaEngine(mediaItems);
it('getAllMedia returns all items with full MediaData fields', async () => {
const result = await engine.getAllMedia();
expect(result).toHaveLength(2);
expect(result[0].filename).toBe('photo.jpg');
expect(result[0].mimeType).toBe('image/jpeg');
expect(result[0].size).toBe(12345);
expect(result[0].width).toBe(800);
expect(result[0].height).toBe(600);
expect(result[0].title).toBe('Beach');
expect(result[0].alt).toBe('Beach photo');
expect(result[0].caption).toBe('A sunny beach');
expect(result[0].author).toBe('Alice');
expect(result[0].language).toBe('en');
expect(result[0].tags).toEqual(['nature', 'beach']);
expect(result[0].linkedPostIds).toEqual(['p1', 'p2']);
expect(result[0].availableLanguages).toEqual(['en', 'de']);
});
it('setProjectContext is a no-op', () => {
expect(() => engine.setProjectContext('proj-1')).not.toThrow();
});
});
describe('DataBackedPostMediaEngine', () => {
const mediaItems: import('../../src/main/engine/MediaEngine').MediaData[] = [
{
id: 'm1', filename: 'photo.jpg', originalName: 'my-photo.jpg',
mimeType: 'image/jpeg', size: 12345, width: 800, height: 600,
title: 'Beach', alt: 'Beach photo', caption: 'A sunny beach',
createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
tags: ['nature'], linkedPostIds: ['p1'], availableLanguages: [],
},
{
id: 'm2', filename: 'sunset.png', originalName: 'sunset.png',
mimeType: 'image/png', size: 5555,
createdAt: new Date('2025-02-01'), updatedAt: new Date('2025-02-01'),
tags: [], linkedPostIds: ['p1'], availableLanguages: [],
},
{
id: 'm3', filename: 'doc.pdf', originalName: 'document.pdf',
mimeType: 'application/pdf', size: 99999,
createdAt: new Date('2025-03-01'), updatedAt: new Date('2025-03-01'),
tags: [], availableLanguages: [],
},
];
// Post p1 is linked to m1 (sort 0) and m2 (sort 1)
const postMediaLinks = new Map<string, Array<{ mediaId: string; sortOrder: number }>>([
['p1', [{ mediaId: 'm1', sortOrder: 0 }, { mediaId: 'm2', sortOrder: 1 }]],
]);
const engine = createDataBackedPostMediaEngine({ mediaItems, postMediaLinks });
it('getLinkedMediaForPost returns links for a post', async () => {
const result = await engine.getLinkedMediaForPost('p1');
expect(result).toHaveLength(2);
expect(result[0].mediaId).toBe('m1');
expect(result[1].mediaId).toBe('m2');
});
it('getLinkedMediaForPost returns empty for unknown post', async () => {
const result = await engine.getLinkedMediaForPost('unknown');
expect(result).toEqual([]);
});
it('getLinkedMediaDataForPost returns links with full media data', async () => {
const result = await engine.getLinkedMediaDataForPost('p1');
expect(result).toHaveLength(2);
expect(result[0].media.id).toBe('m1');
expect(result[0].media.mimeType).toBe('image/jpeg');
expect(result[0].media.caption).toBe('A sunny beach');
expect(result[1].media.id).toBe('m2');
expect(result[1].media.mimeType).toBe('image/png');
});
it('getLinkedMediaDataForPost returns empty for unknown post', async () => {
const result = await engine.getLinkedMediaDataForPost('unknown');
expect(result).toEqual([]);
});
it('setProjectContext is a no-op', () => {
expect(() => engine.setProjectContext('proj-1')).not.toThrow();
});
});

View File

@@ -75,4 +75,65 @@ describe('GenerationPostSnapshotService', () => {
expect(result.publishedPosts.map((post) => post.id).sort()).toEqual(['article', 'page']);
expect(result.publishedListPosts.map((post) => post.id)).toEqual(['article']);
});
it('uses getPublishedVersionsBulk when available for efficient loading', async () => {
const published = makePost({ id: 'pub-1', status: 'published', categories: ['news'] });
const draft = makePost({ id: 'draft-1', status: 'draft', categories: ['news'] });
const pubSnapshot = makePost({ id: 'pub-1', status: 'published', categories: ['news'], title: 'Snapshot Title' });
const draftSnapshot = makePost({ id: 'draft-1', status: 'published', categories: ['news'] });
const engine = makeEngine([published, draft]);
let individualCallCount = 0;
engine.getPublishedVersion = async () => {
individualCallCount++;
return null;
};
engine.getPublishedVersionsBulk = async (ids: string[]) => {
const map = new Map<string, PostData>();
if (ids.includes('pub-1')) map.set('pub-1', pubSnapshot);
if (ids.includes('draft-1')) map.set('draft-1', draftSnapshot);
return map;
};
const result = await loadPublishedGenerationSets(engine, []);
expect(individualCallCount).toBe(0);
expect(result.publishedPosts).toHaveLength(2);
expect(result.publishedPosts.find(p => p.id === 'pub-1')?.title).toBe('Snapshot Title');
});
it('uses bulk loading with list-excluded categories filtered in memory', async () => {
const article = makePost({ id: 'article', status: 'published', categories: ['article'] });
const page = makePost({ id: 'page', status: 'published', categories: ['page'] });
const articleSnapshot = makePost({ id: 'article', status: 'published', categories: ['article'], title: 'Article Snap' });
const pageSnapshot = makePost({ id: 'page', status: 'published', categories: ['page'], title: 'Page Snap' });
const engine = makeEngine([article, page]);
engine.getPublishedVersionsBulk = async (ids: string[]) => {
const map = new Map<string, PostData>();
if (ids.includes('article')) map.set('article', articleSnapshot);
if (ids.includes('page')) map.set('page', pageSnapshot);
return map;
};
const result = await loadPublishedGenerationSets(engine, ['page']);
expect(result.publishedPosts.map(p => p.id).sort()).toEqual(['article', 'page']);
expect(result.publishedListPosts.map(p => p.id)).toEqual(['article']);
});
it('only calls getPostsFiltered twice (published + draft), not four times', async () => {
const post = makePost({ id: 'p1', status: 'published' });
const engine = makeEngine([post]);
let filterCallCount = 0;
const originalGetPostsFiltered = engine.getPostsFiltered;
engine.getPostsFiltered = async (filter) => {
filterCallCount++;
return originalGetPostsFiltered(filter);
};
await loadPublishedGenerationSets(engine, ['some-category']);
expect(filterCallCount).toBe(2);
});
});

View File

@@ -183,4 +183,59 @@ describe('GenerationRouteRendererFactory', () => {
expect(html).toContain('youtube.com/embed/dQw4w9WgXcQ?rel=0');
expect(html).toContain('/assets/bds.css');
});
it('produces correct language flags for subtree pages when projectMainLanguage is set', async () => {
const post = makePost({
id: 'lang-1',
slug: 'lang-post',
title: 'Sprachbeitrag',
content: 'Inhalt des Beitrags.',
createdAt: new Date('2025-01-15T10:00:00.000Z'),
});
const postEngine = {
getPostsFiltered: vi.fn(async () => [post]),
getPublishedVersion: vi.fn(async () => null),
findPublishedBySlug: vi.fn(async (slug: string) => (slug === post.slug ? post : null)),
getPost: vi.fn(async (id: string) => (id === post.id ? post : null)),
hasPublishedVersion: vi.fn(async () => false),
setProjectContext: vi.fn(),
};
const renderRoute = createPreviewBackedGenerationRouteRenderer({
options: {
projectId: 'project',
dataDir: '/tmp',
projectName: 'Project',
language: 'en',
blogLanguages: ['de', 'en'],
},
projectMainLanguage: 'de',
maxPostsPerPage: 50,
publishedPostsForLookup: [post],
languagePrefix: '/en',
engines: {
postEngine,
mediaEngine: {
getAllMedia: vi.fn(async () => []),
setProjectContext: vi.fn(),
},
postMediaEngine: {
setProjectContext: vi.fn(),
getLinkedMediaForPost: vi.fn(async () => []),
getLinkedMediaDataForPost: vi.fn(async () => []),
},
},
});
const html = await renderRoute('/');
// German flag should point to root (main language), not /de/
expect(html).toContain('🇩🇪');
expect(html).toContain('🇬🇧');
// Main language link should have no prefix (root)
expect(html).not.toContain('href="/de');
// English flag should show /en prefix
expect(html).toContain('/en');
});
});

View File

@@ -0,0 +1,236 @@
import { describe, expect, it } from 'vitest';
import {
serializePostData,
deserializePostData,
serializeMediaItem,
deserializeMediaItem,
serializeBlogGenerationOptions,
serializePostMap,
deserializePostMap,
serializeDateMap,
deserializeDateMap,
} from '../../src/main/engine/GenerationWorkerData';
import type { PostData } from '../../src/main/engine/PostEngine';
function makePost(overrides: Partial<PostData> & { id: string; slug: string }): PostData {
return {
projectId: 'proj-1',
title: overrides.slug,
excerpt: 'short',
content: `body of ${overrides.slug}`,
status: 'published',
createdAt: new Date('2025-06-15T12:00:00Z'),
updatedAt: new Date('2025-06-15T14:00:00Z'),
tags: ['a', 'b'],
categories: ['article'],
availableLanguages: ['en'],
...overrides,
};
}
describe('PostData serialization', () => {
it('round-trips a basic post', () => {
const post = makePost({ id: '1', slug: 'hello' });
const serialized = serializePostData(post);
const deserialized = deserializePostData(serialized);
expect(deserialized.id).toBe('1');
expect(deserialized.slug).toBe('hello');
expect(deserialized.createdAt).toBeInstanceOf(Date);
expect(deserialized.createdAt.toISOString()).toBe('2025-06-15T12:00:00.000Z');
expect(deserialized.updatedAt).toBeInstanceOf(Date);
expect(deserialized.tags).toEqual(['a', 'b']);
expect(deserialized.categories).toEqual(['article']);
expect(deserialized.content).toBe('body of hello');
});
it('round-trips publishedAt', () => {
const post = makePost({ id: '2', slug: 'pub', publishedAt: new Date('2025-07-01T00:00:00Z') });
const result = deserializePostData(serializePostData(post));
expect(result.publishedAt).toBeInstanceOf(Date);
expect(result.publishedAt?.toISOString()).toBe('2025-07-01T00:00:00.000Z');
});
it('round-trips undefined publishedAt', () => {
const post = makePost({ id: '3', slug: 'nopub' });
const result = deserializePostData(serializePostData(post));
expect(result.publishedAt).toBeUndefined();
});
it('preserves translation variant fields', () => {
const post = makePost({ id: '4', slug: 'hello.fr' });
(post as any).translationSourceSlug = 'hello';
(post as any).translationCanonicalLanguage = 'en';
(post as any).translationFilePath = '/data/translations/hello.fr.md';
const result = deserializePostData(serializePostData(post));
expect((result as any).translationSourceSlug).toBe('hello');
expect((result as any).translationCanonicalLanguage).toBe('en');
expect((result as any).translationFilePath).toBe('/data/translations/hello.fr.md');
});
it('handles post with Date already as string (defensive)', () => {
const post = makePost({ id: '5', slug: 'strdate' });
(post as any).createdAt = '2025-01-01T00:00:00.000Z';
const serialized = serializePostData(post);
expect(serialized.createdAt).toBe('2025-01-01T00:00:00.000Z');
const deserialized = deserializePostData(serialized);
expect(deserialized.createdAt).toBeInstanceOf(Date);
});
});
describe('MediaItem serialization', () => {
it('round-trips a media item with all fields', () => {
const media = {
id: 'm1',
filename: 'photo.webp',
originalName: 'My Photo.webp',
mimeType: 'image/webp',
size: 54321,
width: 1920,
height: 1080,
title: 'Sunset',
alt: 'A beautiful sunset',
caption: 'Taken at the beach',
author: 'Bob',
language: 'en',
createdAt: new Date('2025-03-01T10:00:00Z'),
updatedAt: new Date('2025-03-02T10:00:00Z'),
tags: ['nature', 'sunset'],
linkedPostIds: ['p1', 'p2'],
availableLanguages: ['en', 'de'],
};
const serialized = serializeMediaItem(media);
expect(serialized.createdAt).toBe('2025-03-01T10:00:00.000Z');
expect(serialized.updatedAt).toBe('2025-03-02T10:00:00.000Z');
expect(serialized.mimeType).toBe('image/webp');
expect(serialized.size).toBe(54321);
expect(serialized.width).toBe(1920);
expect(serialized.title).toBe('Sunset');
expect(serialized.tags).toEqual(['nature', 'sunset']);
expect(serialized.linkedPostIds).toEqual(['p1', 'p2']);
const deserialized = deserializeMediaItem(serialized);
expect(deserialized.createdAt).toBeInstanceOf(Date);
expect(deserialized.updatedAt).toBeInstanceOf(Date);
expect(deserialized.filename).toBe('photo.webp');
expect(deserialized.originalName).toBe('My Photo.webp');
expect(deserialized.mimeType).toBe('image/webp');
expect(deserialized.size).toBe(54321);
expect(deserialized.width).toBe(1920);
expect(deserialized.height).toBe(1080);
expect(deserialized.title).toBe('Sunset');
expect(deserialized.alt).toBe('A beautiful sunset');
expect(deserialized.caption).toBe('Taken at the beach');
expect(deserialized.author).toBe('Bob');
expect(deserialized.language).toBe('en');
expect(deserialized.tags).toEqual(['nature', 'sunset']);
expect(deserialized.linkedPostIds).toEqual(['p1', 'p2']);
expect(deserialized.availableLanguages).toEqual(['en', 'de']);
});
it('round-trips a media item with minimal fields', () => {
const media = {
id: 'm2',
filename: 'doc.pdf',
originalName: 'Document.pdf',
mimeType: 'application/pdf',
size: 999,
createdAt: new Date('2025-04-01T00:00:00Z'),
updatedAt: new Date('2025-04-01T00:00:00Z'),
tags: [],
availableLanguages: [],
};
const deserialized = deserializeMediaItem(serializeMediaItem(media));
expect(deserialized.mimeType).toBe('application/pdf');
expect(deserialized.width).toBeUndefined();
expect(deserialized.title).toBeUndefined();
expect(deserialized.linkedPostIds).toBeUndefined();
expect(deserialized.tags).toEqual([]);
});
});
describe('BlogGenerationOptions serialization', () => {
it('strips fields not needed by worker', () => {
const serialized = serializeBlogGenerationOptions({
projectId: 'p1',
projectName: 'My Blog',
projectDescription: 'A blog',
dataDir: '/data',
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
pageTitle: 'My Blog',
maxPostsPerPage: 50,
picoTheme: undefined,
sections: ['single'],
});
expect(serialized.projectId).toBe('p1');
expect(serialized.baseUrl).toBe('https://example.com');
expect(serialized.blogLanguages).toEqual(['en', 'fr']);
// pageTitle, maxPostsPerPage, sections are not in serialized
expect((serialized as any).sections).toBeUndefined();
});
});
describe('PostMap serialization', () => {
it('round-trips a Map<string, PostData[]>', () => {
const post1 = makePost({ id: '1', slug: 'a' });
const post2 = makePost({ id: '2', slug: 'b' });
const map = new Map<string, PostData[]>([
['tag-js', [post1, post2]],
['tag-py', [post2]],
]);
const serialized = serializePostMap(map);
expect(serialized).toHaveLength(2);
expect(serialized[0][0]).toBe('tag-js');
expect(serialized[0][1]).toHaveLength(2);
const deserialized = deserializePostMap(serialized);
expect(deserialized.size).toBe(2);
expect(deserialized.get('tag-js')).toHaveLength(2);
expect(deserialized.get('tag-js')![0].createdAt).toBeInstanceOf(Date);
});
it('round-trips a Map<number, PostData[]>', () => {
const post1 = makePost({ id: '1', slug: 'a' });
const map = new Map<number, PostData[]>([
[2025, [post1]],
]);
const serialized = serializePostMap(map);
const deserialized = deserializePostMap(serialized);
expect(deserialized.get(2025)).toHaveLength(1);
});
});
describe('DateMap serialization', () => {
it('round-trips a Map<number, Date>', () => {
const map = new Map<number, Date>([
[2024, new Date('2024-01-01')],
[2025, new Date('2025-01-01')],
]);
const serialized = serializeDateMap(map);
expect(serialized).toHaveLength(2);
expect(typeof serialized[0][1]).toBe('string');
const deserialized = deserializeDateMap(serialized);
expect(deserialized.size).toBe(2);
expect(deserialized.get(2024)).toBeInstanceOf(Date);
expect(deserialized.get(2025)).toBeInstanceOf(Date);
});
it('round-trips a Map<string, Date>', () => {
const map = new Map<string, Date>([
['2025/01', new Date('2025-01-15')],
]);
const serialized = serializeDateMap(map);
const deserialized = deserializeDateMap(serialized);
expect(deserialized.get('2025/01')).toBeInstanceOf(Date);
});
});

View File

@@ -0,0 +1,274 @@
import { describe, expect, it, vi } from 'vitest';
import { GenerationWorkerPool, type WorkerLike, type WorkerFactory } from '../../src/main/engine/GenerationWorkerPool';
import type { GenerationWorkerTask, WorkerOutboundMessage } from '../../src/main/engine/GenerationWorkerData';
function makeTask(taskId: string, section: 'single' | 'category' | 'tag' | 'date' = 'single'): GenerationWorkerTask {
return {
taskId,
section,
posts: [],
lookupPosts: [],
mediaItems: [],
backlinksMap: {},
options: {
projectId: 'proj-1',
projectName: 'Test Blog',
dataDir: '/data',
baseUrl: 'https://example.com',
},
maxPostsPerPage: 50,
htmlDir: '/data/html',
hashMapEntries: [],
postFilePathEntries: [],
};
}
function createMockWorkerFactory(
responses: Map<string, WorkerOutboundMessage[]>,
): WorkerFactory {
return (_workerPath: string, workerData: GenerationWorkerTask): WorkerLike => {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const worker: WorkerLike = {
on(event: string, listener: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
},
async terminate() {
return 0;
},
removeAllListeners() {
listeners.clear();
},
};
// Simulate async message delivery
setTimeout(() => {
const taskMessages = responses.get(workerData.taskId) ?? [
{ type: 'result', taskId: workerData.taskId, pagesGenerated: 0 },
];
for (const msg of taskMessages) {
const messageListeners = listeners.get('message') ?? [];
for (const listener of messageListeners) {
listener(msg);
}
}
}, 1);
return worker;
};
}
describe('GenerationWorkerPool', () => {
it('returns zero pages for empty task list', async () => {
const pool = new GenerationWorkerPool({ maxWorkers: 2 });
const result = await pool.runTasks([], vi.fn());
expect(result.pagesGenerated).toBe(0);
expect(result.errors).toHaveLength(0);
});
it('runs a single task and reports result', async () => {
const responses = new Map<string, WorkerOutboundMessage[]>([
['task-1', [{ type: 'result', taskId: 'task-1', pagesGenerated: 42 }]],
]);
const pool = new GenerationWorkerPool(
{ maxWorkers: 2 },
createMockWorkerFactory(responses),
);
const progress = vi.fn();
const result = await pool.runTasks([makeTask('task-1')], progress);
expect(result.pagesGenerated).toBe(42);
expect(result.errors).toHaveLength(0);
});
it('merges results from multiple tasks', async () => {
const responses = new Map<string, WorkerOutboundMessage[]>([
['task-1', [{ type: 'result', taskId: 'task-1', pagesGenerated: 10 }]],
['task-2', [{ type: 'result', taskId: 'task-2', pagesGenerated: 20 }]],
['task-3', [{ type: 'result', taskId: 'task-3', pagesGenerated: 30 }]],
]);
const pool = new GenerationWorkerPool(
{ maxWorkers: 2 },
createMockWorkerFactory(responses),
);
const result = await pool.runTasks(
[makeTask('task-1'), makeTask('task-2'), makeTask('task-3')],
vi.fn(),
);
expect(result.pagesGenerated).toBe(60);
});
it('collects progress messages', async () => {
const responses = new Map<string, WorkerOutboundMessage[]>([
['task-1', [
{ type: 'progress', taskId: 'task-1', message: 'Page 1' },
{ type: 'progress', taskId: 'task-1', message: 'Page 2' },
{ type: 'result', taskId: 'task-1', pagesGenerated: 2 },
]],
]);
const pool = new GenerationWorkerPool(
{ maxWorkers: 1 },
createMockWorkerFactory(responses),
);
const progress = vi.fn();
await pool.runTasks([makeTask('task-1')], progress);
expect(progress).toHaveBeenCalledWith('Page 1');
expect(progress).toHaveBeenCalledWith('Page 2');
});
it('collects errors from failed tasks', async () => {
const responses = new Map<string, WorkerOutboundMessage[]>([
['task-1', [{ type: 'error', taskId: 'task-1', error: 'Render failed' }]],
['task-2', [{ type: 'result', taskId: 'task-2', pagesGenerated: 5 }]],
]);
const pool = new GenerationWorkerPool(
{ maxWorkers: 2 },
createMockWorkerFactory(responses),
);
const result = await pool.runTasks(
[makeTask('task-1'), makeTask('task-2')],
vi.fn(),
);
expect(result.pagesGenerated).toBe(5);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].taskId).toBe('task-1');
expect(result.errors[0].error).toBe('Render failed');
});
it('handles worker crash via error event', async () => {
const factory: WorkerFactory = (_workerPath, workerData): WorkerLike => {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const worker: WorkerLike = {
on(event: string, listener: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
},
async terminate() {
return 1;
},
removeAllListeners() {
listeners.clear();
},
};
setTimeout(() => {
const errorListeners = listeners.get('error') ?? [];
for (const listener of errorListeners) {
listener(new Error('Worker crashed'));
}
}, 1);
return worker;
};
const pool = new GenerationWorkerPool({ maxWorkers: 1 }, factory);
const result = await pool.runTasks([makeTask('crash-task')], vi.fn());
expect(result.errors).toHaveLength(1);
expect(result.errors[0].error).toBe('Worker crashed');
});
it('collects hashUpdates from worker results', async () => {
const responses = new Map<string, WorkerOutboundMessage[]>([
['task-1', [{ type: 'result', taskId: 'task-1', pagesGenerated: 3, hashUpdates: [
{ relativePath: 'index.html', hash: 'aaa' },
{ relativePath: 'page/2/index.html', hash: 'bbb' },
] }]],
['task-2', [{ type: 'result', taskId: 'task-2', pagesGenerated: 2, hashUpdates: [
{ relativePath: 'tags/index.html', hash: 'ccc' },
] }]],
]);
const pool = new GenerationWorkerPool(
{ maxWorkers: 2 },
createMockWorkerFactory(responses),
);
const result = await pool.runTasks(
[makeTask('task-1'), makeTask('task-2')],
vi.fn(),
);
expect(result.pagesGenerated).toBe(5);
expect(result.hashUpdates).toHaveLength(3);
expect(result.hashUpdates).toContainEqual({ relativePath: 'index.html', hash: 'aaa' });
expect(result.hashUpdates).toContainEqual({ relativePath: 'page/2/index.html', hash: 'bbb' });
expect(result.hashUpdates).toContainEqual({ relativePath: 'tags/index.html', hash: 'ccc' });
});
it('returns empty hashUpdates when workers report errors', async () => {
const responses = new Map<string, WorkerOutboundMessage[]>([
['task-1', [{ type: 'error', taskId: 'task-1', error: 'boom' }]],
]);
const pool = new GenerationWorkerPool(
{ maxWorkers: 1 },
createMockWorkerFactory(responses),
);
const result = await pool.runTasks([makeTask('task-1')], vi.fn());
expect(result.hashUpdates).toHaveLength(0);
expect(result.errors).toHaveLength(1);
});
it('respects maxWorkers concurrency', async () => {
let peakConcurrent = 0;
let currentConcurrent = 0;
const factory: WorkerFactory = (_workerPath, workerData): WorkerLike => {
currentConcurrent++;
if (currentConcurrent > peakConcurrent) peakConcurrent = currentConcurrent;
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const worker: WorkerLike = {
on(event: string, listener: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
},
async terminate() {
currentConcurrent--;
return 0;
},
removeAllListeners() {
listeners.clear();
},
};
setTimeout(() => {
const messageListeners = listeners.get('message') ?? [];
for (const listener of messageListeners) {
listener({ type: 'result', taskId: workerData.taskId, pagesGenerated: 1 });
}
}, 5);
return worker;
};
const pool = new GenerationWorkerPool({ maxWorkers: 2 }, factory);
const result = await pool.runTasks(
[makeTask('t1'), makeTask('t2'), makeTask('t3'), makeTask('t4')],
vi.fn(),
);
expect(result.pagesGenerated).toBe(4);
expect(peakConcurrent).toBeLessThanOrEqual(2);
});
});