Feature/post media translations (#42)

* chore: updated todo with translation ideas

* feat: first take at the implementation of translations

* fix: small addition for the translation feature

* feat: support language switching in the editor and preview

* feat: better handling of long bodies by not running them through a json envelope

* fix: unknown macros have better fallback

* feat: api for python to get translations

* fix: strip dumb prefix of content in translation

* feat: extend meta diff for translations

* feat: hook up translations to rebuild-from-disk

* feat: generation of the website prefers project language, falling back to canonical language

* fix: crashes during rendering

* feat: translation validation report

* fix: made the translation validation actually work

* chore: reorganization of menu

* fix: some topics cleanup

* chore: updated doc

* feat: translations for media

* feat: more aligned in UI/UX

* feat: edit translations possible

* chore: added full multi-language todo

* chore: updated todo for clarity

* feat: implementation of full multi-linguality

* fix: page creation creates pages

* fix: flags on every page

* fix: better prompt

* feat: made MCP server aware of language content

* feat: python tools for translations

* fix: better fill-in-translations

* fix: better prompt for translation. maybe.

* fix: losing posts from search due to translation process

* fix: translation validation handles in-db content and fill-in of missing translations fixed to flush

* fix: faster scanning for infilling of missing translations

* chore: updated agent instructions

* feat: calendar and tag cloud respect current language now

* fix: retries going up

* fix: got metadata-diff and rebuild into sync

* fix: extended meta-diff for timestamps

* fix: made website validation look at translated content, too

* fix: multi-lingual search

* chore: refactor Editor.tsx into two separate editors

* feat: do language detection when no explicit language given

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-09 14:43:18 +01:00
committed by GitHub
parent f1c9038803
commit b855d61524
116 changed files with 19954 additions and 2094 deletions

View File

@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, rm, readdir, stat, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import type { PostData } from '../../src/main/engine/PostEngine';
import type { PostData, PostTranslationData } from '../../src/main/engine/PostEngine';
import { resolveUiLanguageFromSystemLocale } from '../../src/main/shared/i18n';
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
import { createPreviewBackedGenerationRouteRenderer } from '../../src/main/engine/GenerationRouteRendererFactory';
const generatedFileHashes = new Map<string, string>();
const generatedFileUpdatedAt = new Map<string, number>();
@@ -72,6 +73,8 @@ vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
getPostsFiltered: vi.fn(async () => []),
getPublishedVersion: vi.fn(async () => null),
getPost: vi.fn(async () => null),
getPostTranslation: vi.fn(async () => null),
getPostTranslations: vi.fn(async () => []),
setProjectContext: vi.fn(),
};
return {
@@ -115,11 +118,13 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? '# Test\n\nBody text',
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt ?? createdAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
availableLanguages: overrides.availableLanguages ?? (overrides.language ? [overrides.language] : []),
};
}
@@ -197,6 +202,8 @@ describe('BlogGenerationEngine', () => {
mockPostEngine.getPost.mockImplementation(async (id: string) => {
return posts.find((p) => p.id === id) ?? null;
});
mockPostEngine.getPostTranslation.mockResolvedValue(null);
mockPostEngine.getPostTranslations.mockResolvedValue([]);
}
async function generate(
@@ -682,11 +689,122 @@ describe('BlogGenerationEngine', () => {
const monthArchivePath = path.join(tempDir, 'html', '2020', '02', 'index.html');
const monthHtml = await readFile(monthArchivePath, 'utf-8');
expect(monthHtml).toContain('<html lang="fr">');
expect(monthHtml).toContain('<html lang="fr"');
expect(monthHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
expect(monthHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
});
it('renders canonical single-post route with project main language content when available', async () => {
const canonicalPost = makePost({
id: 'post-1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nCanonical body',
language: 'en',
createdAt: new Date('2025-01-15T10:00:00Z'),
});
setupPosts([canonicalPost]);
mockPostEngine.getPostTranslation.mockImplementation(async (postId: string, language: string) => {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1-fr',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
};
}
return null;
});
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'fr',
}, vi.fn());
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('post-1', 'fr');
expect(canonicalHtml).toContain('<html lang="fr"');
expect(canonicalHtml).toContain('Bonjour le monde');
expect(canonicalHtml).toContain('Corps FR');
expect(canonicalHtml).not.toContain('Canonical body');
});
it('preview-backed generation route renderer prefers project main language content on canonical single-post routes', async () => {
const canonicalPost = makePost({
id: 'post-1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nCanonical body',
language: 'en',
createdAt: new Date('2025-01-15T10:00:00Z'),
});
const renderRoute = createPreviewBackedGenerationRouteRenderer({
options: {
projectId: 'test',
dataDir: tempDir,
projectName: 'Test Blog',
language: 'fr',
},
maxPostsPerPage: 50,
publishedPostsForLookup: [canonicalPost],
engines: {
postEngine: {
getPostsFiltered: mockPostEngine.getPostsFiltered,
getPublishedVersion: mockPostEngine.getPublishedVersion,
getPost: mockPostEngine.getPost,
getPostTranslation: vi.fn(async (postId: string, language: string) => {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1-fr',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
} satisfies PostTranslationData;
}
return null;
}),
hasPublishedVersion: mockPostEngine.hasPublishedVersion,
setProjectContext: mockPostEngine.setProjectContext,
},
mediaEngine: mockMediaEngine,
postMediaEngine: mockPostMediaEngine,
},
});
const html = await renderRoute('/2025/01/15/hello-world');
expect(html).not.toBeNull();
expect(html).toContain('<html lang="fr"');
expect(html).toContain('Bonjour le monde');
expect(html).toContain('Corps FR');
expect(html).not.toContain('Canonical body');
});
it('excludes draft-only posts from generated pages', async () => {
const posts = [
makePost({ id: '1', slug: 'published', title: 'Published', status: 'published' }),
@@ -1221,6 +1339,121 @@ describe('BlogGenerationEngine', () => {
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
});
it('generates published translation pages with alternate links and sitemap entries', async () => {
const sourcePost = makePost({
id: '1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nEnglish body',
language: 'en',
availableLanguages: ['en', 'fr'],
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
});
const translationsByPostId = new Map<string, PostTranslationData[]>([
['1', [{
id: 'translation-1-fr',
projectId: 'default',
translationFor: '1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
}]],
]);
setupPosts([sourcePost]);
mockPostEngine.getPostTranslations.mockImplementation(async (postId: string) => translationsByPostId.get(postId) ?? []);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn());
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
const translationHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'), 'utf-8');
const sitemap = await readFile(path.join(tempDir, 'html', 'sitemap.xml'), 'utf-8');
expect(canonicalHtml).toContain('hreflang="fr"');
expect(canonicalHtml).toContain('href="/2025/01/15/hello-world.fr"');
expect(translationHtml).toContain('<html lang="fr"');
expect(translationHtml).toContain('Bonjour le monde');
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world/</loc>');
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world.fr/</loc>');
});
it('preserves post engine method binding when loading published translations', async () => {
const sourcePost = makePost({
id: '1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nEnglish body',
language: 'en',
availableLanguages: ['en', 'fr'],
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
});
const translationsByPostId = new Map<string, PostTranslationData[]>([
['1', [{
id: 'translation-1-fr',
projectId: 'default',
translationFor: '1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
}]],
]);
const postEngine = {
translationsByPostId,
setProjectContext: vi.fn(),
async getPostsFiltered(filter: { status?: string }) {
return filter.status === 'published' ? [sourcePost] : [];
},
async getPublishedVersion() {
return null;
},
async getPost(postId: string) {
return postId === sourcePost.id ? sourcePost : null;
},
async getPostTranslations(postId: string) {
return this.translationsByPostId.get(postId) ?? [];
},
};
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(postEngine as any, mockMediaEngine, mockPostMediaEngine);
await expect(engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn())).resolves.toMatchObject({
postCount: 1,
});
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'))).toBe(true);
});
it('applies validation by generating only missing category and tag routes', async () => {
const posts = [
makePost({ id: '1', slug: 'ordered-post', categories: ['news'], tags: ['ordered-tag'], createdAt: new Date('2025-01-15T10:00:00Z') }),
@@ -1617,6 +1850,49 @@ describe('BlogGenerationEngine', () => {
expect(await fileExists(path.join(tempDir, 'html', 'hello-world', 'index.html'))).toBe(false);
});
it('generates translated static page routes for published page translations', async () => {
const pagePost = makePost({
id: 'page-1',
slug: 'tag-cloud',
title: 'Tag Cloud',
categories: ['page'],
language: 'en',
availableLanguages: ['en', 'de'],
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
});
setupPosts([pagePost]);
mockPostEngine.getPostTranslations.mockResolvedValue([{
id: 'translation-page-1-de',
projectId: 'default',
translationFor: 'page-1',
language: 'de',
title: 'Schlagwortwolke',
excerpt: 'Zusammenfassung DE',
content: '# Schlagwortwolke\n\nInhalt DE',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'tag-cloud.de.md'),
}]);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn());
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud.de', 'index.html'))).toBe(true);
});
it('generates canonical post routes only and does not generate aliases', async () => {
const posts = [
makePost({ id: '1', slug: 'alias-test', createdAt: new Date('2025-03-15T10:00:00Z') }),
@@ -1705,6 +1981,167 @@ describe('BlogGenerationEngine', () => {
expect(await fileExists(path.join(tempDir, 'html', 'media'))).toBe(false);
});
it('validateSite reports missing language subtree pages and does not flag them as extra', async () => {
const posts = [
makePost({
id: '1',
slug: 'lang-post',
title: 'Language Post',
categories: ['news'],
tags: ['lang-tag'],
createdAt: new Date('2025-01-15T10:00:00Z'),
}),
];
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
// Generate only main language pages
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn());
// Validate with blogLanguages including fr - should report missing fr pages
const report = await engine.validateSite({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
expect(report.missingUrlPaths).toContain('/fr');
expect(report.missingUrlPaths).toContain('/fr/2025/01/15/lang-post');
expect(report.missingUrlPaths).toContain('/fr/category/news');
expect(report.missingUrlPaths).toContain('/fr/tag/lang-tag');
expect(report.extraUrlPaths).not.toContain('/fr');
});
it('validateSite reports no missing language pages after full multi-language generation', async () => {
const posts = [
makePost({
id: '1',
slug: 'multilang-post',
title: 'Multi Lang Post',
categories: ['news'],
createdAt: new Date('2025-01-15T10:00:00Z'),
}),
];
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
const report = await engine.validateSite({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
expect(report.missingUrlPaths).toEqual([]);
expect(report.extraUrlPaths).toEqual([]);
});
it('applyValidation renders missing language subtree pages', async () => {
const posts = [
makePost({
id: '1',
slug: 'apply-lang-post',
title: 'Apply Lang Post',
categories: ['news'],
tags: ['apply-lang-tag'],
createdAt: new Date('2025-01-15T10:00:00Z'),
}),
];
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const result = await engine.applyValidation({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, {
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
sitemapChanged: false,
missingUrlPaths: ['/fr/category/news', '/fr/tag/apply-lang-tag'],
extraUrlPaths: [],
updatedPostUrlPaths: [],
expectedUrlCount: 2,
existingHtmlUrlCount: 0,
}, vi.fn());
expect(result.renderedUrlCount).toBeGreaterThan(0);
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'category', 'news', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'tag', 'apply-lang-tag', 'index.html'))).toBe(true);
});
it('validateSite excludes doNotTranslate posts from language subtree expected urls', async () => {
const translatablePost = makePost({
id: '1',
slug: 'translatable',
title: 'Translatable',
categories: ['news'],
createdAt: new Date('2025-01-15T10:00:00Z'),
});
const dntPost = makePost({
id: '2',
slug: 'no-translate',
title: 'Do Not Translate',
categories: ['news'],
createdAt: new Date('2025-01-16T10:00:00Z'),
doNotTranslate: true,
} as any);
setupPosts([translatablePost, dntPost]);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
const report = await engine.validateSite({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
expect(report.missingUrlPaths).toEqual([]);
// The dnt post's single page should NOT be expected in /fr/ subtree
expect(report.extraUrlPaths).not.toContain('/fr/2025/01/16/no-translate');
});
it('generates zero pages when there are no published posts', async () => {
const result = await generate([]);
expect(result.pagesGenerated).toBe(0);

View File

@@ -27,6 +27,8 @@ function createMockPostEngine() {
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
getPostTranslation: vi.fn().mockResolvedValue(null),
getPostTranslations: vi.fn().mockResolvedValue([]),
};
}
@@ -1198,6 +1200,81 @@ describe('MCPServer', () => {
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('not found');
});
it('read_post_by_slug with language returns translation content', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
const translation = { id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English content', excerpt: 'English excerpt', status: 'published', createdAt: new Date(), updatedAt: new Date() };
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getPostTranslation.mockResolvedValue(translation);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'read_post_by_slug');
const result = await tool.handler({ slug: 'hallo-welt', language: 'en' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.post.title).toBe('Hello World');
expect(parsed.post.content).toBe('English content');
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
});
it('read_post_by_slug with canonical language returns original post', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'read_post_by_slug');
const result = await tool.handler({ slug: 'hallo-welt', language: 'de' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.post.title).toBe('Hallo Welt');
expect(parsed.post.content).toBe('Deutscher Inhalt');
expect(mockPostEngine.getPostTranslation).not.toHaveBeenCalled();
});
it('read_post_by_slug returns error when translation not found', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutsch', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de'], createdAt: new Date(), updatedAt: new Date() };
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getPostTranslation.mockResolvedValue(null);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'read_post_by_slug');
const result = await tool.handler({ slug: 'hallo-welt', language: 'fr' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('fr');
});
// ── get_post_translations tool ──────────────────────────────────
it('registers get_post_translations tool', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'get_post_translations')).toBe(true);
});
it('get_post_translations returns all translations for a post', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', language: 'de', status: 'published', tags: [], categories: [], createdAt: new Date(), updatedAt: new Date() };
const translations = [
{ id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English', status: 'published', createdAt: new Date(), updatedAt: new Date() },
{ id: 't2', translationFor: 'p1', language: 'fr', title: 'Bonjour le Monde', content: 'French', status: 'published', createdAt: new Date(), updatedAt: new Date() },
];
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getPostTranslations.mockResolvedValue(translations);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'get_post_translations');
const result = await tool.handler({ slug: 'hallo-welt' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.translations).toHaveLength(2);
expect(parsed.translations[0].language).toBe('en');
expect(parsed.translations[1].language).toBe('fr');
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
});
it('get_post_translations returns error for nonexistent slug', async () => {
mockPostEngine.getPostBySlug.mockResolvedValue(null);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'get_post_translations');
const result = await tool.handler({ slug: 'no-such-slug' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
});
});
// ── Prompt handler behavior ────────────────────────────────────────

View File

@@ -0,0 +1,491 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaEngine } from '../../src/main/engine/MediaEngine';
import { media, mediaTranslations } from '../../src/main/database/schema';
const mockMedia = new Map<string, any>();
const mockTranslations = new Map<string, any>();
const mockFiles = new Map<string, string>();
function resetData(): void {
mockMedia.clear();
mockTranslations.clear();
mockFiles.clear();
}
function getTableRows(table: unknown): any[] {
if (table === media) {
return Array.from(mockMedia.values());
}
if (table === mediaTranslations) {
return Array.from(mockTranslations.values());
}
return [];
}
function extractEqValue(predicate: unknown): string | undefined {
const chunks = (predicate as any)?.queryChunks;
if (!Array.isArray(chunks)) return undefined;
for (const chunk of chunks) {
if (chunk?.value !== undefined && typeof chunk.value === 'string') {
return chunk.value;
}
}
return undefined;
}
function createSelectChain() {
let selectedTable: unknown;
let filterValue: string | undefined;
return {
from: vi.fn().mockImplementation(function from(table: unknown) {
selectedTable = table;
return this;
}),
where: vi.fn().mockImplementation(function where(predicate: unknown) {
filterValue = extractEqValue(predicate);
return this;
}),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(async () => {
const rows = getTableRows(selectedTable);
if (filterValue) {
return rows.filter((row) =>
row.id === filterValue ||
row.translationFor === filterValue ||
row.projectId === filterValue
);
}
return rows;
}),
get: vi.fn().mockImplementation(async () => {
const rows = getTableRows(selectedTable);
if (filterValue) {
return rows.find((row) =>
row.id === filterValue ||
row.translationFor === filterValue ||
row.projectId === filterValue
);
}
return rows[0];
}),
};
}
function createInsertChain(table: unknown) {
return {
values: vi.fn(async (value: any) => {
const rows = Array.isArray(value) ? value : [value];
for (const row of rows) {
if (table === media) {
mockMedia.set(row.id, row);
} else if (table === mediaTranslations) {
mockTranslations.set(row.id, row);
}
}
}),
};
}
function createUpdateChain(table: unknown) {
return {
set: vi.fn().mockImplementation((value: Record<string, unknown>) => ({
where: vi.fn(async (predicate: unknown) => {
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
if (!targetMap || targetMap.size === 0) return;
const targetId = extractEqValue(predicate);
if (targetId && targetMap.has(targetId)) {
const existing = targetMap.get(targetId);
targetMap.set(targetId, { ...existing, ...value });
}
}),
})),
};
}
function createDeleteChain(table: unknown) {
return {
where: vi.fn(async (predicate: unknown) => {
const targetId = extractEqValue(predicate);
if (targetId) {
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
if (targetMap) {
// Try direct key match first
if (targetMap.has(targetId)) {
targetMap.delete(targetId);
} else {
// Filter by translationFor (cascade delete pattern)
for (const [key, row] of targetMap.entries()) {
if ((row as any).translationFor === targetId || (row as any).mediaId === targetId) {
targetMap.delete(key);
}
}
}
}
}
}),
};
}
const mockLocalDb = {
select: vi.fn(() => createSelectChain()),
insert: vi.fn((table: unknown) => createInsertChain(table)),
update: vi.fn((table: unknown) => createUpdateChain(table)),
delete: vi.fn((table: unknown) => createDeleteChain(table)),
};
const mockLocalClient = {
execute: vi.fn(async () => ({ rows: [] })),
};
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => mockLocalClient),
})),
}));
vi.mock('fs/promises', () => ({
access: vi.fn(async (filePath: string) => {
if (!mockFiles.has(filePath)) {
const error = new Error('ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
}),
mkdir: vi.fn(async () => {}),
readFile: vi.fn(async (filePath: string) => {
const content = mockFiles.get(filePath);
if (content == null) {
const error = new Error('ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
}),
readdir: vi.fn(async () => []),
rename: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
writeFile: vi.fn(async (filePath: string, content: string) => {
mockFiles.set(filePath, content);
}),
copyFile: vi.fn(async () => {}),
stat: vi.fn(async () => ({ size: 1024 })),
}));
vi.mock('uuid', () => {
let counter = 1;
return {
v4: vi.fn(() => `uuid-${counter++}`),
};
});
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/electron-test'),
},
}));
function seedMediaItem(overrides: Partial<any> = {}): any {
const id = overrides.id || 'media-1';
const item = {
id,
projectId: 'project-1',
filename: `${id}.jpg`,
originalName: 'photo.jpg',
mimeType: 'image/jpeg',
size: 1024,
width: 800,
height: 600,
title: 'A photo',
alt: 'Alt text',
caption: 'Photo caption',
author: 'Author',
language: null,
filePath: `/tmp/project-1/media/2024/01/${id}.jpg`,
sidecarPath: `/tmp/project-1/media/2024/01/${id}.jpg.meta`,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
checksum: 'abc123',
tags: '[]',
...overrides,
};
mockMedia.set(id, item);
return item;
}
describe('Media translation system', () => {
let engine: MediaEngine;
beforeEach(() => {
vi.clearAllMocks();
resetData();
engine = new MediaEngine();
engine.setProjectContext('project-1', '/tmp/project-1');
});
describe('getMediaTranslation', () => {
it('returns null when no translation exists', async () => {
seedMediaItem();
const result = await engine.getMediaTranslation('media-1', 'fr');
expect(result).toBeNull();
});
it('returns a translation when one exists', async () => {
seedMediaItem();
mockTranslations.set('trans-1', {
id: 'trans-1',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.getMediaTranslation('media-1', 'fr');
expect(result).toMatchObject({
id: 'trans-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
});
});
describe('getMediaTranslations', () => {
it('returns all translations for a media item', async () => {
seedMediaItem();
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
mockTranslations.set('trans-de', {
id: 'trans-de',
projectId: 'project-1',
translationFor: 'media-1',
language: 'de',
title: 'Ein Foto',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.getMediaTranslations('media-1');
expect(result).toHaveLength(2);
expect(result.map(t => t.language).sort()).toEqual(['de', 'fr']);
});
it('returns empty array when no translations exist', async () => {
seedMediaItem();
const result = await engine.getMediaTranslations('media-1');
expect(result).toEqual([]);
});
});
describe('upsertMediaTranslation', () => {
it('creates a new translation', async () => {
seedMediaItem({ language: 'en' });
const result = await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
expect(result).toMatchObject({
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
expect(result.id).toBeTruthy();
});
it('updates an existing translation instead of creating duplicates', async () => {
seedMediaItem({ language: 'en' });
const first = await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Titre v1',
});
const second = await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Titre v2',
alt: 'Alt v2',
});
expect(second.id).toBe(first.id);
const translations = await engine.getMediaTranslations('media-1');
expect(translations).toHaveLength(1);
expect(translations[0].title).toBe('Titre v2');
});
it('rejects translations whose language matches the canonical media language', async () => {
seedMediaItem({ language: 'de' });
await expect(
engine.upsertMediaTranslation('media-1', 'DE', {
title: 'Titel',
})
).rejects.toThrow('Translation language must differ from canonical media language');
});
it('rejects translations for non-existent media', async () => {
await expect(
engine.upsertMediaTranslation('nonexistent', 'fr', { title: 'Test' })
).rejects.toThrow('Media item not found');
});
});
describe('deleteMediaTranslation', () => {
it('deletes an existing translation', async () => {
seedMediaItem();
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.deleteMediaTranslation('media-1', 'fr');
expect(result).toBe(true);
});
it('returns false when translation does not exist', async () => {
seedMediaItem();
const result = await engine.deleteMediaTranslation('media-1', 'fr');
expect(result).toBe(false);
});
});
describe('availableLanguages on media', () => {
it('includes canonical language and translation languages', async () => {
seedMediaItem({ language: 'en' });
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.getMedia('media-1');
expect(result?.availableLanguages).toEqual(['en', 'fr']);
});
it('returns empty array when no language is set and no translations exist', async () => {
seedMediaItem();
const result = await engine.getMedia('media-1');
expect(result?.availableLanguages).toEqual([]);
});
});
describe('translated sidecar I/O', () => {
it('writes a translated sidecar file with language suffix', async () => {
seedMediaItem({ language: 'en' });
await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
// Verify a sidecar file was written at the .fr.meta path
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.fr.meta';
expect(mockFiles.has(sidecarPath)).toBe(true);
const content = mockFiles.get(sidecarPath)!;
expect(content).toContain('language: fr');
expect(content).toContain('title: "Une photo"');
expect(content).toContain('alt: "Texte alt"');
});
it('reads a translated sidecar file', async () => {
const sidecarContent = [
'---',
'translationFor: media-1',
'language: de',
'title: "Ein Foto"',
'alt: "Alt-Text"',
'caption: "Bildunterschrift"',
'---',
].join('\n');
mockFiles.set('/tmp/project-1/media/2024/01/media-1.jpg.de.meta', sidecarContent);
const result = await engine.readTranslatedSidecarFile(
'/tmp/project-1/media/2024/01/media-1.jpg.de.meta'
);
expect(result).toMatchObject({
translationFor: 'media-1',
language: 'de',
title: 'Ein Foto',
alt: 'Alt-Text',
caption: 'Bildunterschrift',
});
});
it('returns null for non-existent sidecar', async () => {
const result = await engine.readTranslatedSidecarFile(
'/tmp/nonexistent.fr.meta'
);
expect(result).toBeNull();
});
});
describe('canonical sidecar includes language', () => {
it('includes language field in sidecar when set on media', async () => {
seedMediaItem({ language: 'en' });
// Trigger a sidecar write via updateMedia
await engine.updateMedia('media-1', { language: 'en' });
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.meta';
if (mockFiles.has(sidecarPath)) {
const content = mockFiles.get(sidecarPath)!;
expect(content).toContain('language: en');
}
});
});
describe('deleteMedia cascades to translations', () => {
it('deletes all translations when media is deleted', async () => {
seedMediaItem();
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
await engine.deleteMedia('media-1');
// Translations should be cleaned up
expect(mockTranslations.size).toBe(0);
});
});
});

View File

@@ -123,12 +123,19 @@ vi.mock('gray-matter', () => ({
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
let value = line.slice(colonIndex + 1).trim();
let value: any = line.slice(colonIndex + 1).trim();
// Parse arrays
if (value.startsWith('[') && value.endsWith(']')) {
value = JSON.parse(value.replace(/'/g, '"'));
}
// Parse booleans
else if (value === 'true') {
value = true;
}
else if (value === 'false') {
value = false;
}
// Parse strings
else if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
@@ -160,13 +167,17 @@ vi.mock('../../src/main/engine/TaskManager', () => ({
// Track the mock function for PostEngine.syncPublishedPostFile
const mockSyncPublishedPostFile = vi.fn(async () => true);
const mockSyncPublishedPostTranslationFile = vi.fn(async () => true);
const mockImportOrphanFile = vi.fn(async () => ({ id: 'imported-id', title: 'Imported' }));
const mockImportOrphanTranslationFile = vi.fn(async () => ({ id: 'imported-translation-id', title: 'Imported translation' }));
// Mock PostEngine
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
syncPublishedPostFile: mockSyncPublishedPostFile,
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
importOrphanFile: mockImportOrphanFile,
importOrphanTranslationFile: mockImportOrphanTranslationFile,
})),
}));
@@ -179,10 +190,23 @@ describe('MetadataDiffEngine', () => {
mockPostsGetQueue = [];
mockFileData.clear();
mockAllPostsRows = [];
mockSyncPublishedPostFile.mockClear();
mockImportOrphanFile.mockClear();
mockLocalDb.select.mockImplementation(() => createSelectChain(mockAllPostsRows));
mockLocalDb.update.mockImplementation(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
}) as any);
mockSyncPublishedPostFile.mockReset().mockResolvedValue(true);
mockSyncPublishedPostTranslationFile.mockReset().mockResolvedValue(true);
mockImportOrphanFile.mockReset().mockResolvedValue({ id: 'imported-id', title: 'Imported' });
mockImportOrphanTranslationFile.mockReset().mockResolvedValue({ id: 'imported-translation-id', title: 'Imported translation' });
resetMockCounters();
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile, importOrphanFile: mockImportOrphanFile } as any);
engine = new MetadataDiffEngine({
syncPublishedPostFile: mockSyncPublishedPostFile,
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
importOrphanFile: mockImportOrphanFile,
importOrphanTranslationFile: mockImportOrphanTranslationFile,
} as any);
engine.setProjectContext('test-project');
});
@@ -498,38 +522,48 @@ Content here`);
describe('scanAllPublishedPosts', () => {
it('should scan all published posts and return differences', async () => {
// Mock the raw SQL query that returns published posts
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{
id: 'post-1',
title: 'Post 1',
slug: 'post-1',
file_path: '/mock/userData/posts/2024/01/post-1.md',
tags: '["new-tag"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
{
id: 'post-2',
title: 'Post 2',
slug: 'post-2',
file_path: '/mock/userData/posts/2024/01/post-2.md',
tags: '["tag1"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
],
});
// Mock the second query that gets ALL post file paths (for orphan detection)
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
],
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
return {
rows: [
{
id: 'post-1',
title: 'Post 1',
slug: 'post-1',
file_path: '/mock/userData/posts/2024/01/post-1.md',
tags: '["new-tag"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
{
id: 'post-2',
title: 'Post 2',
slug: 'post-2',
file_path: '/mock/userData/posts/2024/01/post-2.md',
tags: '["tag1"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
],
};
}
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
return { rows: [] };
}
if (query.sql.includes('SELECT file_path FROM posts')) {
return {
rows: [
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
],
};
}
if (query.sql.includes('SELECT file_path FROM post_translations')) {
return { rows: [] };
}
return { rows: [] };
});
// Queue the posts for sequential .get() calls in comparePostMetadata
@@ -545,6 +579,7 @@ Content here`);
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
},
{
id: 'post-2',
@@ -557,6 +592,7 @@ Content here`);
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
},
];
@@ -734,6 +770,93 @@ Content`);
// Draft post file should NOT be flagged as orphan since it's in the DB
expect(result.orphanFiles).toEqual([]);
});
it('should include published translation metadata differences in the scan', async () => {
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
return { rows: [] };
}
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
return {
rows: [
{
id: 'translation-1',
translation_for: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
file_path: '/mock/posts/2024/01/post-1.de.md',
},
],
};
}
if (query.sql.includes('SELECT file_path FROM posts')) {
return { rows: [] };
}
if (query.sql.includes('SELECT file_path FROM post_translations')) {
return { rows: [{ file_path: '/mock/posts/2024/01/post-1.de.md' }] };
}
return { rows: [] };
});
let selectCall = 0;
mockLocalDb.select.mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockImplementation(() => {
selectCall += 1;
if (selectCall === 1) return Promise.resolve(undefined);
if (selectCall === 2) {
return Promise.resolve({
id: 'translation-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
content: null,
status: 'published',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
filePath: '/mock/posts/2024/01/post-1.de.md',
});
}
return Promise.resolve({
id: 'post-1',
projectId: 'test-project',
title: 'Hello World',
slug: 'post-1',
status: 'published',
filePath: '/mock/posts/2024/01/post-1.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
});
}),
});
return chain;
});
mockFileData.set('/mock/posts/2024/01/post-1.de.md', `---
translationFor: post-1
language: de
title: "Hallo Welt"
excerpt: "File excerpt"
---
Translated content`);
const result = await engine.scanAllPublishedPosts((current, total) => {}, '/mock/posts');
expect(result.totalScanned).toBe(1);
expect(result.postsWithDifferences).toBe(1);
expect(result.differences).toHaveLength(1);
expect(result.differences[0].postId).toBe('translation-1');
expect(result.differences[0].differences.excerpt).toEqual({ dbValue: 'DB excerpt', fileValue: 'File excerpt' });
expect(result.orphanFiles).toEqual([]);
});
});
describe('importOrphanFiles', () => {
@@ -778,6 +901,22 @@ Content`);
expect(progressCalls[0][0]).toBe(5);
expect(progressCalls[0][1]).toBe(5);
});
it('should import orphan translation files into the translations table path', async () => {
mockFileData.set('/posts/2024/01/post.de.md', `---
translationFor: post-1
language: de
title: "Hallo Welt"
excerpt: "Translated excerpt"
---
Translated content`);
const result = await engine.importOrphanFiles(['/posts/2024/01/post.de.md']);
expect(result).toEqual({ success: 1, failed: 0 });
expect(mockImportOrphanTranslationFile).toHaveBeenCalledWith('/posts/2024/01/post.de.md');
expect(mockImportOrphanFile).not.toHaveBeenCalled();
});
});
describe('groupDifferencesByField', () => {
@@ -856,12 +995,26 @@ Content`);
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('sync failure'));
mockSyncPublishedPostTranslationFile
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
const result = await engine.syncDbToFile(['post-1', 'post-2', 'post-3']);
expect(result).toEqual({ success: 1, failed: 2 });
expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(3);
});
it('should fall back to syncing published translation files when the post file sync does not apply', async () => {
mockSyncPublishedPostFile.mockResolvedValueOnce(false);
mockSyncPublishedPostTranslationFile.mockResolvedValueOnce(true);
const result = await engine.syncDbToFile(['translation-1']);
expect(result).toEqual({ success: 1, failed: 0 });
expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('translation-1');
expect(mockSyncPublishedPostTranslationFile).toHaveBeenCalledWith('translation-1');
});
});
describe('syncFileToDb', () => {
@@ -1019,6 +1172,49 @@ Content here`);
expect(result).toEqual({ success: 1, failed: 2 });
expect(mockLocalDb.update).toHaveBeenCalledTimes(1);
});
it('should sync published translation metadata from file to database', async () => {
let selectCall = 0;
mockLocalDb.select.mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockImplementation(() => {
selectCall += 1;
if (selectCall === 1) return Promise.resolve(undefined);
return Promise.resolve({
id: 'translation-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
status: 'published',
filePath: '/mock/userData/posts/2024/01/post-1.de.md',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
});
}),
});
return chain;
});
mockFileData.set('/mock/userData/posts/2024/01/post-1.de.md', `---
translationFor: post-1
language: de
title: "Hallo Datei"
excerpt: "File excerpt"
---
Translated content`);
await engine.syncFileToDb(['translation-1'], 'title');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.title).toBe('Hallo Datei');
});
});
// ── Media diff tests ──
@@ -1383,4 +1579,595 @@ Content here`);
});
});
});
describe('comparePostMetadata new fields', () => {
it('should detect doNotTranslate differences between DB and file', async () => {
const dbPost = {
id: 'post-dnt',
projectId: 'test-project',
title: 'DNT Post',
slug: 'dnt-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/dnt-post.md',
tags: '[]',
categories: '[]',
doNotTranslate: true,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-dnt', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/dnt-post.md', `---
id: post-dnt
projectId: test-project
title: "DNT Post"
slug: dnt-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-dnt');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.doNotTranslate).toBeDefined();
expect(result?.differences.doNotTranslate?.dbValue).toBe(true);
expect(result?.differences.doNotTranslate?.fileValue).toBe(false);
});
it('should detect templateSlug differences between DB and file', async () => {
const dbPost = {
id: 'post-tpl',
projectId: 'test-project',
title: 'Template Post',
slug: 'template-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/template-post.md',
tags: '[]',
categories: '[]',
templateSlug: 'blog-default',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-tpl', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/template-post.md', `---
id: post-tpl
projectId: test-project
title: "Template Post"
slug: template-post
status: published
templateSlug: old-template
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-tpl');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.templateSlug).toBeDefined();
expect(result?.differences.templateSlug?.dbValue).toBe('blog-default');
expect(result?.differences.templateSlug?.fileValue).toBe('old-template');
});
it('should detect status differences between DB and file', async () => {
const dbPost = {
id: 'post-status',
projectId: 'test-project',
title: 'Archived Post',
slug: 'archived-post',
status: 'archived',
filePath: '/mock/userData/posts/2024/01/archived-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-status', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/archived-post.md', `---
id: post-status
projectId: test-project
title: "Archived Post"
slug: archived-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-status');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.status).toBeDefined();
expect(result?.differences.status?.dbValue).toBe('archived');
expect(result?.differences.status?.fileValue).toBe('published');
});
it('should show no differences when new fields match', async () => {
const dbPost = {
id: 'post-match',
projectId: 'test-project',
title: 'Matching Post',
slug: 'matching-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/matching-post.md',
tags: '[]',
categories: '[]',
doNotTranslate: false,
templateSlug: null,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-match', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/matching-post.md', `---
id: post-match
projectId: test-project
title: "Matching Post"
slug: matching-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-match');
expect(result?.hasDifferences).toBe(false);
});
});
describe('syncFileToDb new fields', () => {
it('should sync doNotTranslate from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
doNotTranslate: true
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'doNotTranslate');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.doNotTranslate).toBe(true);
});
it('should sync templateSlug from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
templateSlug: my-template
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'templateSlug');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.templateSlug).toBe('my-template');
});
it('should sync status from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: archived
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'status');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.status).toBe('archived');
});
});
describe('groupDifferencesByField new fields', () => {
it('should include new fields in group labels', () => {
const diffs: PostMetadataDiff[] = [
{
postId: 'p1',
title: 'Post 1',
slug: 'post-1',
hasDifferences: true,
differences: {
doNotTranslate: { dbValue: true, fileValue: false },
templateSlug: { dbValue: 'tpl-a', fileValue: 'tpl-b' },
status: { dbValue: 'published', fileValue: 'archived' },
},
},
];
const groups = engine.groupDifferencesByField(diffs);
const fieldNames = groups.map(g => g.field);
expect(fieldNames).toContain('doNotTranslate');
expect(fieldNames).toContain('templateSlug');
expect(fieldNames).toContain('status');
const dntGroup = groups.find(g => g.field === 'doNotTranslate');
expect(dntGroup?.label).toBe('Do Not Translate');
const tplGroup = groups.find(g => g.field === 'templateSlug');
expect(tplGroup?.label).toBe('Template');
const statusGroup = groups.find(g => g.field === 'status');
expect(statusGroup?.label).toBe('Status');
});
it('should include timestamp fields in group labels', () => {
const diffs: PostMetadataDiff[] = [
{
postId: 'p1',
title: 'Post 1',
slug: 'post-1',
hasDifferences: true,
differences: {
createdAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-02-15T00:00:00.000Z' },
updatedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-03-01T00:00:00.000Z' },
publishedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '' },
},
},
];
const groups = engine.groupDifferencesByField(diffs);
const fieldNames = groups.map(g => g.field);
expect(fieldNames).toContain('createdAt');
expect(fieldNames).toContain('updatedAt');
expect(fieldNames).toContain('publishedAt');
expect(groups.find(g => g.field === 'createdAt')?.label).toBe('Created At');
expect(groups.find(g => g.field === 'updatedAt')?.label).toBe('Updated At');
expect(groups.find(g => g.field === 'publishedAt')?.label).toBe('Published At');
});
});
describe('comparePostMetadata timestamp diffs', () => {
it('should detect createdAt differences between DB and file', async () => {
const dbPost = {
id: 'post-ts',
projectId: 'test-project',
title: 'Timestamp Post',
slug: 'timestamp-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/timestamp-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
};
mockPosts.set('post-ts', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/timestamp-post.md', `---
id: post-ts
projectId: test-project
title: "Timestamp Post"
slug: timestamp-post
status: published
tags: []
categories: []
createdAt: 2024-06-01T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-ts');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.createdAt).toBeDefined();
expect(result?.differences.createdAt?.fileValue).toContain('2024-06-01');
});
it('should detect publishedAt differences when DB has it but file does not', async () => {
const dbPost = {
id: 'post-pa',
projectId: 'test-project',
title: 'Published At Post',
slug: 'published-at-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-at-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
};
mockPosts.set('post-pa', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-at-post.md', `---
id: post-pa
projectId: test-project
title: "Published At Post"
slug: published-at-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-pa');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.publishedAt).toBeDefined();
});
it('should show no timestamp differences when they match at second precision', async () => {
const dbPost = {
id: 'post-eq',
projectId: 'test-project',
title: 'Equal Post',
slug: 'equal-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/equal-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T12:30:00.000Z'),
updatedAt: new Date('2024-01-15T12:30:00.000Z'),
publishedAt: new Date('2024-01-15T12:30:00.000Z'),
};
mockPosts.set('post-eq', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/equal-post.md', `---
id: post-eq
projectId: test-project
title: "Equal Post"
slug: equal-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T12:30:00.000Z
updatedAt: 2024-01-15T12:30:00.000Z
publishedAt: 2024-01-15T12:30:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-eq');
expect(result?.hasDifferences).toBe(false);
expect(result?.differences.createdAt).toBeUndefined();
expect(result?.differences.updatedAt).toBeUndefined();
expect(result?.differences.publishedAt).toBeUndefined();
});
});
describe('syncFileToDb timestamp fields', () => {
it('should sync createdAt from file to database using file value', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: []
createdAt: 2024-06-01T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'createdAt');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.createdAt).toBeInstanceOf(Date);
expect(setCall.createdAt.toISOString()).toContain('2024-06-01');
// Should NOT auto-set updatedAt when syncing a timestamp field
expect(setCall.updatedAt).toBeUndefined();
});
it('should auto-set updatedAt when syncing a non-timestamp field', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: ["new-tag"]
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'tags');
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
// Should auto-set updatedAt for non-timestamp field syncs
expect(setCall.updatedAt).toBeInstanceOf(Date);
});
});
describe('compareMediaMetadata language diff', () => {
let mediaEngine: MetadataDiffEngine;
const mockReadSidecarFile = vi.fn();
beforeEach(() => {
mediaEngine = new MetadataDiffEngine(
undefined,
{ readSidecarFile: mockReadSidecarFile, getMedia: vi.fn(), updateMedia: vi.fn() } as any,
);
mediaEngine.setProjectContext('test-project');
});
it('should detect media language differences', async () => {
mockPostsGetQueue = [{
id: 'media-1',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Photo',
alt: 'A photo',
caption: '',
author: '',
language: 'en',
tags: '[]',
}];
mockReadSidecarFile.mockResolvedValueOnce({
id: 'media-1',
originalName: 'photo.jpg',
title: 'Photo',
alt: 'A photo',
caption: '',
author: '',
language: 'fr',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-1');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.language).toBeDefined();
expect(result?.differences.language?.dbValue).toBe('en');
expect(result?.differences.language?.fileValue).toBe('fr');
});
it('should not flag language when both are empty', async () => {
mockPostsGetQueue = [{
id: 'media-2',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Photo',
alt: '',
caption: '',
author: '',
language: null,
tags: '[]',
}];
mockReadSidecarFile.mockResolvedValueOnce({
title: 'Photo',
alt: '',
caption: '',
author: '',
language: '',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-2');
expect(result?.differences.language).toBeUndefined();
});
});
});

View File

@@ -109,7 +109,7 @@ describe('replaceAllMacrosAsync', () => {
expect(result).toContain('<aside class="custom-box">Custom Content</aside>');
});
it('returns empty string for unknown macros without Python renderer', async () => {
it('preserves unknown macros without Python renderer', async () => {
const result = await replaceAllMacrosAsync(
'Before [[unknown_macro]] After',
'post-1',
@@ -120,10 +120,10 @@ describe('replaceAllMacrosAsync', () => {
null,
);
expect(result).toBe('Before After');
expect(result).toBe('Before [[unknown_macro]] After');
});
it('returns empty string for unmatched Python macros', async () => {
it('preserves unmatched Python macros', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([]),
renderMacro: vi.fn(),
@@ -139,7 +139,7 @@ describe('replaceAllMacrosAsync', () => {
mockRenderer,
);
expect(result).toBe('Before After');
expect(result).toBe('Before [[nonexistent_macro]] After');
expect(mockRenderer.renderMacro).not.toHaveBeenCalled();
});
@@ -186,7 +186,21 @@ describe('replaceAllMacrosAsync', () => {
mockRenderer,
);
expect(result).toBe('Before After');
expect(result).toBe('Before [[my_macro]] After');
});
it('preserves the original unknown macro tag including params', async () => {
const result = await replaceAllMacrosAsync(
'Before [[unknown_macro title="Hello" count="2"]] After',
'post-1',
[],
null,
[],
'en',
null,
);
expect(result).toBe('Before [[unknown_macro title="Hello" count="2"]] After');
});
it('does not look up Python scripts when all macros are built-in', async () => {
@@ -244,6 +258,73 @@ describe('replaceAllMacrosAsync', () => {
expect(call.cacheKey).toBe('ctx-script:2');
});
it('passes languagePrefix and translations in Python macro context', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'lang-script',
slug: 'lang_test',
entrypoint: 'render',
content: 'def render(ctx, post): return {"html": "ok"}',
version: 1,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
};
await replaceAllMacrosAsync(
'[[lang_test]]',
'post-1',
[],
null,
[],
'fr',
mockRenderer,
null,
'/fr',
);
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
const parsedContext = JSON.parse(call.contextJson);
expect(parsedContext.env.languagePrefix).toBe('/fr');
expect(parsedContext.env.mainLanguage).toBe('fr');
expect(parsedContext.env.translations).toBeDefined();
expect(typeof parsedContext.env.translations).toBe('object');
expect(parsedContext.env.translations['render.archive']).toBe('Archives');
});
it('passes empty languagePrefix when not provided', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'no-prefix-script',
slug: 'no_prefix',
entrypoint: 'render',
content: 'def render(ctx, post): return {"html": "ok"}',
version: 1,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
};
await replaceAllMacrosAsync(
'[[no_prefix]]',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
const parsedContext = JSON.parse(call.contextJson);
expect(parsedContext.env.languagePrefix).toBe('');
expect(parsedContext.env.translations).toBeDefined();
});
it('returns unchanged text when there are no macros', async () => {
const content = 'Just plain text with no macros';
const result = await replaceAllMacrosAsync(content, '', [], null, [], 'en', null);

View File

@@ -7,6 +7,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PostEngine, PostData } from '../../src/main/engine/PostEngine';
import { postTranslations } from '../../src/main/database/schema';
import { resetMockCounters } from '../utils/factories';
import * as fs from 'fs/promises';
@@ -1838,6 +1839,71 @@ Content`);
expect(insertedProjects).toHaveLength(1);
expect(insertedProjects[0]).toBe('current-project-id');
});
it('should rebuild published translation files into the translations table', async () => {
const fs = await import('fs/promises');
const insertedRows: Array<{ table: unknown; data: any }> = [];
vi.mocked(mockLocalDb.insert).mockImplementation((table: unknown) => ({
values: vi.fn((data: any) => {
insertedRows.push({ table, data });
if (data && data.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
}) as any);
vi.mocked(fs.readdir).mockResolvedValueOnce([
mockDirent('source-post.md'),
mockDirent('source-post.de.md'),
] as any);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
if (filePath.includes('source-post.md') && !filePath.includes('source-post.de.md')) {
return `---
id: source-post-id
projectId: default
title: Source Post
slug: source-post
status: published
language: en
createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-02T00:00:00.000Z
publishedAt: 2024-01-02T00:00:00.000Z
tags: []
categories: []
---
Canonical content`;
}
if (filePath.includes('source-post.de.md')) {
return `---
translationFor: source-post-id
language: de
title: Quellbeitrag
excerpt: Deutsche Zusammenfassung
---
Deutscher Inhalt`;
}
throw new Error('ENOENT');
});
await postEngine.rebuildDatabaseFromFiles();
const translationInsert = insertedRows.find((row) => row.table === postTranslations);
expect(translationInsert).toBeDefined();
expect(translationInsert?.data).toMatchObject({
projectId: 'default',
translationFor: 'source-post-id',
language: 'de',
title: 'Quellbeitrag',
excerpt: 'Deutsche Zusammenfassung',
status: 'published',
filePath: expect.stringContaining('source-post.de.md'),
});
});
});
describe('Date-based folder structure', () => {
@@ -3787,4 +3853,198 @@ Body.`);
expect(result!.slug).toBe('existing-slug-2');
});
});
describe('FTS translation indexing', () => {
it('should include translation content in FTS index when updating a post', async () => {
// Arrange: set up a post with a German translation
postEngine.setMainLanguage('en');
postEngine.setSearchLanguage('english');
// Mock getTranslationRowsForPost to return a translation
const translationRow = {
id: 'trans-1',
projectId: 'default',
translationFor: 'post-1',
language: 'de',
title: 'German Title Häuser',
excerpt: 'German Excerpt',
content: 'German draft content Haus',
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: '',
checksum: null,
};
// getAllTranslationRows returns all translations for the current project
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.all = vi.fn().mockResolvedValue([translationRow]);
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue(undefined),
all: vi.fn().mockResolvedValue([translationRow]),
});
return chain;
});
mockExecuteArgs = [];
// Act
await postEngine.updateFTSIndex({
id: 'post-1',
projectId: 'test-project',
title: 'English Title',
content: 'English content about houses',
excerpt: 'Summary',
tags: ['test'],
categories: ['blog'],
});
// Assert: the FTS insert should contain both English and German stemmed content
const ftsInsert = mockExecuteArgs.find((q: { sql: string }) =>
q.sql.includes('INSERT INTO posts_fts'),
);
expect(ftsInsert).toBeDefined();
const indexedContent = ftsInsert.args[2] as string;
// English content should be stemmed with English stemmer
expect(indexedContent).toContain('hous'); // "houses" stemmed in English
// German content should be stemmed with German stemmer
expect(indexedContent).toContain('haus'); // "Haus/Häuser" stemmed in German
});
it('should re-index FTS when a translation is created', async () => {
// Arrange: source post exists
const sourcePost = {
id: 'post-1',
projectId: 'test-project',
title: 'Source Post',
slug: 'source-post',
excerpt: null,
content: 'Source content',
status: 'draft',
author: null,
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: null,
checksum: null,
tags: '[]',
categories: '[]',
language: 'en',
translationOfId: null,
templateSlug: null,
doNotTranslate: 0,
version: 1,
stemmedTitle: '',
stemmedContent: '',
};
let selectCallCount = 0;
vi.mocked(mockLocalDb.select).mockImplementation(() => {
selectCallCount++;
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue(selectCallCount <= 2 ? sourcePost : undefined),
all: vi.fn().mockResolvedValue(selectCallCount <= 2 ? [sourcePost] : []),
});
chain.all = vi.fn().mockResolvedValue([]);
return chain;
});
mockExecuteArgs = [];
// Act: create a French translation
await postEngine.upsertPostTranslation('post-1', 'fr', {
title: 'Titre Français',
content: 'Contenu en français avec des maisons',
});
// Assert: FTS should have been updated (at least one INSERT INTO posts_fts)
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
q.sql.includes('INSERT INTO posts_fts'),
);
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
});
it('should stem search query with multiple languages for cross-language matching', async () => {
postEngine.setMainLanguage('en');
postEngine.setSearchLanguage('english');
// Mock translations with German language to simulate a project with translations
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.all = vi.fn().mockResolvedValue([
{ id: 'trans-1', projectId: 'test-project', translationFor: 'post-1', language: 'de', title: 'T', content: 'C', status: 'draft', createdAt: new Date(), updatedAt: new Date(), publishedAt: null, filePath: '', checksum: null },
]);
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({ id: 'post-1', title: 'Found', slug: 'found', excerpt: null, tags: '[]', categories: '[]' }),
});
return chain;
});
mockLocalClient.execute.mockResolvedValueOnce({ rows: [{ id: 'post-1' }] });
await postEngine.searchPosts('Häuser');
// Verify the FTS MATCH query was called
const matchCall = mockLocalClient.execute.mock.calls[0]?.[0] as { sql: string; args: any[] };
expect(matchCall.sql).toContain('MATCH');
// The query should contain stems from multiple languages combined with OR
const matchArg = matchCall.args[1] as string;
expect(matchArg).toBeDefined();
});
it('should rebuild FTS index including translation content', async () => {
postEngine.setMainLanguage('en');
postEngine.setSearchLanguage('english');
const translationRow = {
id: 'trans-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Deutscher Titel',
excerpt: null,
content: 'Deutscher Inhalt',
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: '',
checksum: null,
};
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([
{ id: 'post-1', projectId: 'test-project', title: 'English Post', content: 'English content', tags: '[]', categories: '[]', language: 'en' },
]),
get: vi.fn().mockResolvedValue(undefined),
});
chain.all = vi.fn().mockResolvedValue([translationRow]);
return chain;
});
mockExecuteArgs = [];
await postEngine.rebuildFTSIndex();
// Verify FTS was populated
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
q.sql.includes('INSERT INTO posts_fts'),
);
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
// The indexed content should include German translation content
const insertContent = ftsInserts[0]?.args?.[2] as string;
expect(insertContent).toBeDefined();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ type PostEngineLike = {
};
type SettingsEngineLike = {
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string } | null>;
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string; blogLanguages?: string[] } | null>;
setProjectContext: (projectId: string, dataDir?: string) => void;
};
@@ -39,6 +39,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
@@ -463,7 +464,6 @@ describe('PreviewServer', () => {
postMediaEngine,
settingsEngine: settingsEngine as any,
menuEngine,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
});
@@ -618,6 +618,142 @@ describe('PreviewServer', () => {
expect(draftHtml).not.toContain('Published body');
});
it('serves translated draft content for single post route when lang query param is provided', async () => {
const draftPost = makePost({
id: 'post-2',
slug: 'shared-slug',
title: 'Draft Title',
content: 'Draft-only body',
status: 'draft',
language: 'en',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
});
server = new PreviewServer({
postEngine: {
...makeEngine([draftPost]),
async getPostTranslation(postId: string, language: string) {
if (postId === 'post-2' && language === 'fr') {
return {
id: 'translation-2-fr',
translationFor: 'post-2',
language: 'fr',
title: 'Titre brouillon',
excerpt: 'Resume brouillon',
content: 'Contenu brouillon traduit',
status: 'draft',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
publishedAt: null,
filePath: 'posts/shared-slug.fr.md',
};
}
return null;
},
} as any,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const draftResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug?draft=true&postId=post-2&lang=fr`);
expect(draftResponse.status).toBe(200);
const draftHtml = await draftResponse.text();
expect(draftHtml).toContain('Titre brouillon');
expect(draftHtml).toContain('Contenu brouillon traduit');
expect(draftHtml).toContain('<html lang="fr"');
expect(draftHtml).not.toContain('Draft-only body');
});
it('prefers project main language content for canonical single post route and falls back to canonical content when unavailable', async () => {
const publishedPost = makePost({
id: 'post-1',
slug: 'shared-slug',
title: 'Published Title',
content: 'Published body',
status: 'published',
language: 'en',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
});
server = new PreviewServer({
postEngine: {
...makeEngine([publishedPost]),
async getPostTranslation(postId: string, language: string) {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1-fr',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Titre publie',
excerpt: 'Resume FR',
content: 'Contenu FR',
status: 'published',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
publishedAt: new Date('2025-01-03T11:00:00.000Z'),
filePath: 'posts/shared-slug.fr.md',
};
}
return null;
},
} as any,
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const translatedResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
expect(translatedResponse.status).toBe(200);
const translatedHtml = await translatedResponse.text();
expect(translatedHtml).toContain('<html lang="fr"');
expect(translatedHtml).toContain('Titre publie');
expect(translatedHtml).toContain('Contenu FR');
expect(translatedHtml).not.toContain('Published body');
server = new PreviewServer({
postEngine: {
...makeEngine([publishedPost]),
async getPostTranslation() {
return null;
},
} as any,
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const fallbackResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
expect(fallbackResponse.status).toBe(200);
const fallbackHtml = await fallbackResponse.text();
expect(fallbackHtml).toContain('Published Title');
expect(fallbackHtml).toContain('Published body');
expect(fallbackHtml).not.toContain('Contenu FR');
});
it('uses selected pico theme stylesheet from project metadata', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
@@ -674,11 +810,87 @@ describe('PreviewServer', () => {
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('<html lang="en" data-theme="dark">');
expect(html).toContain('<html lang="en" data-language-prefix="" data-theme="dark">');
expect(html).toContain('href="/assets/pico.green.min.css"');
expect(html).toContain('/assets/bds.css');
});
it('renders language switcher with flags on style preview when blogLanguages configured', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return {
maxPostsPerPage: 50,
mainLanguage: 'en',
blogLanguages: ['en', 'de'],
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/__style-preview`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('class="language-switcher"');
expect(html).toContain('🇬🇧');
expect(html).toContain('🇩🇪');
});
it('includes mainLanguage in language switcher even when not listed in blogLanguages', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return {
maxPostsPerPage: 50,
mainLanguage: 'de',
blogLanguages: ['en'],
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
// Check style-preview route
const styleResponse = await fetch(`${server.getBaseUrl()}/__style-preview`);
expect(styleResponse.status).toBe(200);
const styleHtml = await styleResponse.text();
expect(styleHtml).toContain('class="language-switcher"');
expect(styleHtml).toContain('🇩🇪');
expect(styleHtml).toContain('🇬🇧');
// Check root route
const rootResponse = await fetch(`${server.getBaseUrl()}/`);
expect(rootResponse.status).toBe(200);
const rootHtml = await rootResponse.text();
expect(rootHtml).toContain('class="language-switcher"');
expect(rootHtml).toContain('🇩🇪');
expect(rootHtml).toContain('🇬🇧');
// Check language-prefixed route (/en/)
const enResponse = await fetch(`${server.getBaseUrl()}/en/`);
expect(enResponse.status).toBe(200);
const enHtml = await enResponse.text();
expect(enHtml).toContain('class="language-switcher"');
expect(enHtml).toContain('🇩🇪');
expect(enHtml).toContain('🇬🇧');
});
it('limits list routes to 50 posts', async () => {
const posts = Array.from({ length: 60 }).map((_, index) =>
makePost({
@@ -1179,7 +1391,7 @@ describe('PreviewServer', () => {
await server.start(0);
const monthPageHtml = await (await fetch(`${server.getBaseUrl()}/2020/2/`)).text();
expect(monthPageHtml).toContain('<html lang="fr">');
expect(monthPageHtml).toContain('<html lang="fr"');
expect(monthPageHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
expect(monthPageHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
});
@@ -1579,7 +1791,7 @@ describe('PreviewServer', () => {
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('<html lang="de">');
expect(html).toContain('<html lang="de"');
});
it('initializes metadata before reading language when supported by settings engine', async () => {
@@ -1610,7 +1822,7 @@ describe('PreviewServer', () => {
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('<html lang="fr">');
expect(html).toContain('<html lang="fr"');
});
it('falls back to active project name in page title when metadata is unavailable', async () => {
@@ -1692,7 +1904,7 @@ describe('PreviewServer', () => {
postMediaEngine: makePostMediaEngine({}) as any,
settingsEngine: makeSettings(50),
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
});
await server.start(0);
@@ -2068,4 +2280,59 @@ describe('PreviewServer', () => {
expect(html).toContain('data-template="not-found"');
expect(html).toContain('class="not-found"');
});
it('returns 503 after stop is called', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const baseUrl = server.getBaseUrl();
const okResponse = await fetch(`${baseUrl}/`);
expect(okResponse.status).toBe(200);
await server.stop();
await expect(fetch(`${baseUrl}/`)).rejects.toThrow();
});
it('includes translation variant slugs in rewrite context using batch method', async () => {
const publishedPost = makePost({
id: 'post-1',
slug: 'hello',
status: 'published',
language: 'en',
createdAt: new Date('2025-02-15T10:00:00.000Z'),
});
const getPublishedTranslationLanguagesByPost = vi.fn(async () => {
const map = new Map<string, string[]>();
map.set('post-1', ['de', 'fr']);
return map;
});
server = new PreviewServer({
postEngine: {
...makeEngine([publishedPost]),
getPublishedTranslationLanguagesByPost,
} as any,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
expect(getPublishedTranslationLanguagesByPost).toHaveBeenCalled();
});
});

View File

@@ -21,6 +21,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
@@ -50,8 +51,8 @@ function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData |
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.excludeCategories && filter.excludeCategories.length > 0) {
result = result.filter((post) => !filter.excludeCategories!.some((category: string) => post.categories.includes(category)));
}
if (filter.year !== undefined) {
@@ -116,6 +117,118 @@ describe('SharedSnapshotService', () => {
expect(result?.id).toBe('draft-1');
});
it('returns a translated draft variant when preview language is provided', async () => {
const draft = makePost({ id: 'draft-1', slug: 'my-post', status: 'draft', language: 'en', title: 'Canonical title', content: 'Canonical body', createdAt: new Date('2025-03-21T10:00:00.000Z') });
const engine = {
...makeEngine([draft]),
async getPostTranslation(postId: string, language: string) {
if (postId === 'draft-1' && language === 'fr') {
return {
id: 'translation-1',
translationFor: 'draft-1',
language: 'fr',
title: 'Titre traduit',
excerpt: 'Resume traduit',
content: 'Contenu traduit',
status: 'draft',
createdAt: new Date('2025-03-21T10:00:00.000Z'),
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
publishedAt: null,
filePath: 'posts/my-post.fr.md',
};
}
return null;
},
} as SharedSnapshotPostEngine & {
getPostTranslation: (postId: string, language: string) => Promise<any>;
};
const result = await findSinglePostBySlug(
engine,
'my-post',
{ useDraftContent: true, draftPostId: 'draft-1', lang: 'fr' },
{ year: 2025, month: 3, day: 21 },
);
expect(result).toMatchObject({
id: 'translation-1',
slug: 'my-post.fr',
language: 'fr',
title: 'Titre traduit',
excerpt: 'Resume traduit',
content: 'Contenu traduit',
});
});
it('prefers project main language content and falls back to canonical language when no translation exists', async () => {
const published = makePost({
id: 'post-1',
slug: 'my-post',
status: 'published',
language: 'en',
title: 'Canonical title',
content: 'Canonical body',
createdAt: new Date('2025-03-21T10:00:00.000Z'),
});
const getPostTranslation = vi.fn(async (postId: string, language: string) => {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Titre principal',
excerpt: 'Resume principal',
content: 'Contenu principal',
status: 'published',
createdAt: new Date('2025-03-21T10:00:00.000Z'),
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
publishedAt: new Date('2025-03-21T11:00:00.000Z'),
filePath: 'posts/my-post.fr.md',
};
}
return null;
});
const engine = {
...makeEngine([published]),
getPostTranslation,
} as SharedSnapshotPostEngine & {
getPostTranslation: (postId: string, language: string) => Promise<any>;
};
const translated = await findSinglePostBySlug(
engine,
'my-post',
{ preferredLanguage: 'fr' },
{ year: 2025, month: 3, day: 21 },
);
expect(translated).toMatchObject({
id: 'translation-1',
slug: 'my-post.fr',
language: 'fr',
title: 'Titre principal',
content: 'Contenu principal',
});
const fallback = await findSinglePostBySlug(
engine,
'my-post',
{ preferredLanguage: 'de' },
{ year: 2025, month: 3, day: 21 },
);
expect(fallback).toMatchObject({
id: 'post-1',
slug: 'my-post',
language: 'en',
title: 'Canonical title',
content: 'Canonical body',
});
expect(getPostTranslation).toHaveBeenNthCalledWith(1, 'post-1', 'fr');
expect(getPostTranslation).toHaveBeenNthCalledWith(2, 'post-1', 'de');
});
it('uses findPublishedBySlug shortcut when present', async () => {
const post = makePost({ id: 'x1', slug: 'shortcut', status: 'published' });
const engine = makeEngine([post]);

View File

@@ -92,4 +92,49 @@ describe('ValidationApplyPlannerService', () => {
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
expect(targeted.requestRootRoutes).toBe(true);
});
it('classifies language-prefixed missing paths into per-language plans', () => {
const plan = planMissingValidationPaths(
[
'/fr/',
'/fr/page/2',
'/fr/category/news',
'/fr/tag/dev',
'/fr/2025/01/15/my-post',
'/fr/2025',
'/fr/2025/01',
'/fr/about',
'/de/',
'/de/category/tech',
],
['fr', 'de'],
);
expect(plan.requiresFallbackSectionRender).toBe(false);
expect(plan.requestRootRoutes).toBe(false);
const frPlan = plan.languagePlans.get('fr');
expect(frPlan).toBeDefined();
expect(frPlan!.requestRootRoutes).toBe(true);
expect(Array.from(frPlan!.requestedCategories)).toEqual(['news']);
expect(Array.from(frPlan!.requestedTags)).toEqual(['dev']);
expect(frPlan!.requestedPostRoutes).toEqual([
{ year: 2025, month: 1, day: 15, slug: 'my-post' },
]);
expect(Array.from(frPlan!.requestedYears)).toContain(2025);
expect(Array.from(frPlan!.requestedYearMonths)).toContain('2025/01');
expect(Array.from(frPlan!.requestedPageSlugs)).toEqual(['about']);
const dePlan = plan.languagePlans.get('de');
expect(dePlan).toBeDefined();
expect(dePlan!.requestRootRoutes).toBe(true);
expect(Array.from(dePlan!.requestedCategories)).toEqual(['tech']);
});
it('treats unknown prefixes as page slugs when no languages specified', () => {
const plan = planMissingValidationPaths(['/fr/category/news', '/fr/']);
expect(plan.languagePlans.size).toBe(0);
expect(plan.requiresFallbackSectionRender).toBe(true);
});
});

View File

@@ -43,6 +43,7 @@ function createMockDeps() {
const postEngine = {
getPost: vi.fn(),
upsertPostTranslation: vi.fn(),
} as any;
return { chatEngine, providers, mediaEngine, postEngine };
@@ -182,3 +183,283 @@ describe('OneShotTasks.analyzePost', () => {
expect(deps.providers.resolveModel).toHaveBeenCalledWith('custom-model-id');
});
});
describe('OneShotTasks.translatePost', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('instructs the AI to leave fenced code blocks untranslated', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Resume"}',
} as any)
.mockResolvedValueOnce({
text: 'Intro\n\n```ts\nconst label = "Hello";\n```',
} as any);
await tasks.translatePost('post-1', 'fr');
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
system: expect.stringContaining('Leave text inside fenced code blocks untranslated.'),
}));
});
it('translates markdown body outside a JSON envelope', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Intro\n\n```ts\nconst config = { hello: "world" };\n```\n\nEnd.',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: 'Bonjour',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Resume"}',
} as any)
.mockResolvedValueOnce({
text: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
} as any);
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
expect(mockGenerateText).toHaveBeenCalledTimes(2);
expect(mockGenerateText).toHaveBeenNthCalledWith(1, expect.objectContaining({
system: expect.stringContaining('keys title and excerpt'),
}));
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
system: expect.stringContaining('Return ONLY the translated Markdown body'),
}));
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
title: 'Mon Post',
excerpt: 'Resume',
content: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
});
});
it('passes the raw markdown body without a leading content label', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: '# Heading\n\nBody text',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: '# Titre\n\nTexte',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Resume"}',
} as any)
.mockResolvedValueOnce({
text: '# Titre\n\nTexte',
} as any);
await tasks.translatePost('post-1', 'fr');
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
prompt: '# Heading\n\nBody text',
}));
});
it('strips an accidental leading content label from translated markdown', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Mein Beitrag',
excerpt: 'Zusammenfassung',
content: '# Uberschrift\n\nText',
language: 'de',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'My Post',
excerpt: 'Summary',
content: '# Heading\n\nText',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"My Post","excerpt":"Summary"}',
} as any)
.mockResolvedValueOnce({
text: 'content:\n\n# Heading\n\nText',
} as any);
await tasks.translatePost('post-1', 'en');
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'en', {
title: 'My Post',
excerpt: 'Summary',
content: '# Heading\n\nText',
});
});
it('instructs the AI to translate only — never invent or add content', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: '# Hello\n\nWorld',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: '# Bonjour\n\nMonde',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"Mon Post","excerpt":"Resume"}' } as any)
.mockResolvedValueOnce({ text: '# Bonjour\n\nMonde' } as any);
await tasks.translatePost('post-1', 'fr');
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
expect(contentSystemPrompt).toMatch(/do not invent|do not add|only translate/i);
expect(contentSystemPrompt).toMatch(/macro|shortcode|non-translatable/i);
});
it('instructs the AI to keep markdown link text unchanged and translate surrounding prose', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'USearch Library',
excerpt: '',
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - drin was drauf steht. Eine Library.',
language: 'de',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'USearch Library',
excerpt: '',
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"USearch Library","excerpt":""}' } as any)
.mockResolvedValueOnce({
text: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
} as any);
await tasks.translatePost('post-1', 'en');
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
expect(contentSystemPrompt).toMatch(/link text/i);
expect(contentSystemPrompt).toMatch(/url/i);
});
it('falls back to source title and excerpt when metadata JSON is invalid', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Intro',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'My Post',
excerpt: 'Summary',
content: 'Bonjour',
});
mockGenerateText
.mockResolvedValueOnce({
text: 'not valid json',
} as any)
.mockResolvedValueOnce({
text: 'Bonjour',
} as any);
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
title: 'My Post',
excerpt: 'Summary',
content: 'Bonjour',
});
});
it('passes status published when autoPublish is set', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Hello world',
language: 'en',
status: 'published',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Résumé',
content: 'Bonjour le monde',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Résumé"}',
} as any)
.mockResolvedValueOnce({
text: 'Bonjour le monde',
} as any);
const result = await tasks.translatePost('post-1', 'fr', { autoPublish: true });
expect(result.success).toBe(true);
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
title: 'Mon Post',
excerpt: 'Résumé',
content: 'Bonjour le monde',
status: 'published',
});
});
});

View File

@@ -0,0 +1,386 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the AI SDK generateText before importing OneShotTasks
vi.mock('ai', () => ({
generateText: vi.fn(),
}));
// Mock i18n
vi.mock('../../../src/main/shared/i18n', () => ({
resolveSupportedRenderLanguage: vi.fn((lang: string) => lang),
translateRender: vi.fn((_lang: string, key: string) => key),
}));
import { OneShotTasks } from '../../../src/main/engine/ai/tasks';
import { generateText } from 'ai';
const mockGenerateText = vi.mocked(generateText);
function createMockDeps() {
const chatEngine = {
getSetting: vi.fn().mockResolvedValue(null),
} as any;
const providers = {
detectModelProvider: vi.fn().mockReturnValue('opencode'),
isProviderKeySet: vi.fn().mockReturnValue(true),
getOpencodeKey: vi.fn().mockReturnValue('test-key'),
getMistralKey: vi.fn().mockReturnValue(null),
resolveModel: vi.fn().mockReturnValue('mock-model'),
isOfflineMode: vi.fn().mockReturnValue(false),
isOllamaModel: vi.fn().mockReturnValue(false),
isLmstudioModel: vi.fn().mockReturnValue(false),
getFirstKnownLocalModelId: vi.fn().mockReturnValue(null),
} as any;
const mediaEngine = {
getMedia: vi.fn(),
updateMedia: vi.fn(),
upsertMediaTranslation: vi.fn(),
} as any;
const postEngine = {
getPost: vi.fn(),
updatePost: vi.fn(),
upsertPostTranslation: vi.fn(),
} as any;
return { chatEngine, providers, mediaEngine, postEngine };
}
describe('translatePost: auto-detect language when not set', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('detects and persists language before translating when post has no language', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Mein Beitrag',
excerpt: 'Zusammenfassung',
content: 'Dies ist ein deutscher Blogbeitrag über verschiedene Themen.',
language: undefined, // No language set
status: 'draft',
});
deps.postEngine.updatePost.mockResolvedValue({
id: 'post-1',
language: 'de',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'My Post',
excerpt: 'Summary',
content: 'This is a German blog post about various topics.',
});
// First call: language detection response
// Then: metadata translation response
// Then: content translation response
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
.mockResolvedValueOnce({ text: '{"title":"My Post","excerpt":"Summary"}' } as any) // metadata translation
.mockResolvedValueOnce({ text: 'This is a German blog post about various topics.' } as any); // content translation
const result = await tasks.translatePost('post-1', 'en');
expect(result.success).toBe(true);
// Language detection should have been called (first generateText call)
expect(mockGenerateText).toHaveBeenCalledTimes(3);
// Language should have been persisted
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'de' });
// Translation prompts should use detected language 'de', not fallback 'en'
const metadataCall = mockGenerateText.mock.calls[1][0];
expect((metadataCall as any).system).toContain('de');
});
it('skips detection when post has explicit language set', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'This is an English blog post.',
language: 'en', // Explicitly set
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Article',
excerpt: 'Résumé',
content: 'Ceci est un article de blog en anglais.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any) // metadata translation
.mockResolvedValueOnce({ text: 'Ceci est un article de blog en anglais.' } as any); // content translation
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
// Only 2 calls: metadata + content translation, NO language detection
expect(mockGenerateText).toHaveBeenCalledTimes(2);
// updatePost should NOT have been called for language
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
});
it('proceeds with fallback when language detection fails', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Some content here.',
language: undefined,
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Article',
excerpt: 'Résumé',
content: 'Du contenu ici.',
});
mockGenerateText
.mockResolvedValueOnce({ text: 'garbage response' } as any) // language detection fails
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any)
.mockResolvedValueOnce({ text: 'Du contenu ici.' } as any);
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
// updatePost should NOT have been called since detection failed
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
// Should still proceed with translation (3 calls total: detection + 2 translation)
expect(mockGenerateText).toHaveBeenCalledTimes(3);
});
it('uses detected language in translation prompts', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Mon Article',
excerpt: '',
content: 'Ceci est un article français.',
language: undefined,
status: 'draft',
});
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'fr' });
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'My Article',
excerpt: '',
content: 'This is a French article.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "fr"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"My Article","excerpt":""}' } as any)
.mockResolvedValueOnce({ text: 'This is a French article.' } as any);
await tasks.translatePost('post-1', 'en');
// The translation prompt should use 'fr' as source language
const metadataSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
const contentSystemPrompt = mockGenerateText.mock.calls[2][0].system as string;
expect(metadataSystemPrompt).toContain('from fr to en');
expect(contentSystemPrompt).toContain('from fr to en');
});
it('treats null language the same as undefined (no language)', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test Post',
excerpt: '',
content: 'Some content.',
language: null, // null, not undefined
status: 'draft',
});
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'en' });
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'de',
title: 'Test',
excerpt: '',
content: 'Inhalt.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"Test","excerpt":""}' } as any)
.mockResolvedValueOnce({ text: 'Inhalt.' } as any);
await tasks.translatePost('post-1', 'de');
// Should have called detection since language is null
expect(mockGenerateText).toHaveBeenCalledTimes(3);
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'en' });
});
});
describe('translateMediaMetadata: auto-detect language when not set', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('detects and persists language before translating when media has no language', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Sonnenuntergang am Meer',
alt: 'Ein wunderschöner Sonnenuntergang',
caption: 'Aufgenommen im Sommer',
language: undefined,
});
deps.mediaEngine.updateMedia.mockResolvedValue({
id: 'media-1',
language: 'de',
});
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'en',
title: 'Sunset at Sea',
alt: 'A beautiful sunset',
caption: 'Taken in summer',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
.mockResolvedValueOnce({ text: '{"title":"Sunset at Sea","alt":"A beautiful sunset","caption":"Taken in summer"}' } as any); // translation
const result = await tasks.translateMediaMetadata('media-1', 'en');
expect(result.success).toBe(true);
expect(mockGenerateText).toHaveBeenCalledTimes(2);
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'de' });
// Translation prompt should use detected language 'de'
const translationCall = mockGenerateText.mock.calls[1][0];
expect((translationCall as any).system).toContain('de');
});
it('skips detection when media has explicit language set', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Sunset at Sea',
alt: 'A beautiful sunset',
caption: 'Taken in summer',
language: 'en',
});
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'de',
title: 'Sonnenuntergang',
alt: 'Schön',
caption: 'Im Sommer',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"Sonnenuntergang","alt":"Schön","caption":"Im Sommer"}' } as any);
const result = await tasks.translateMediaMetadata('media-1', 'de');
expect(result.success).toBe(true);
expect(mockGenerateText).toHaveBeenCalledTimes(1);
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
});
it('proceeds with fallback when media language detection fails', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Test Image',
alt: 'Alt text',
caption: 'Caption',
language: undefined,
});
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'de',
title: 'Testbild',
alt: 'Alt',
caption: 'Beschriftung',
});
mockGenerateText
.mockResolvedValueOnce({ text: 'garbage' } as any) // detection fails
.mockResolvedValueOnce({ text: '{"title":"Testbild","alt":"Alt","caption":"Beschriftung"}' } as any);
const result = await tasks.translateMediaMetadata('media-1', 'de');
expect(result.success).toBe(true);
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
expect(mockGenerateText).toHaveBeenCalledTimes(2);
});
it('uses detected language in media translation prompts', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Tramonto sul mare',
alt: 'Un bel tramonto',
caption: 'Fotografato in estate',
language: undefined,
});
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'it' });
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'en',
title: 'Sunset',
alt: 'Beautiful',
caption: 'Summer',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "it"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"Sunset","alt":"Beautiful","caption":"Summer"}' } as any);
await tasks.translateMediaMetadata('media-1', 'en');
const translationSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
expect(translationSystemPrompt).toContain('from it to en');
});
it('treats null language the same as undefined (no language)', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Test',
alt: 'Alt',
caption: null,
language: null,
});
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'en' });
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'de',
title: 'Test',
alt: 'Alt',
caption: null,
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"Test","alt":"Alt","caption":null}' } as any);
await tasks.translateMediaMetadata('media-1', 'de');
expect(mockGenerateText).toHaveBeenCalledTimes(2);
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'en' });
});
});

View File

@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest';
import { retryWithBackoff } from '../../../src/main/engine/ai/retry';
describe('retryWithBackoff', () => {
it('returns immediately on success (no retries)', async () => {
const fn = vi.fn(async () => ({ success: true, value: 42 }));
const result = await retryWithBackoff(fn);
expect(result).toEqual({ success: true, value: 42 });
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries up to maxRetries times with exponential delays on failure', async () => {
vi.useFakeTimers();
const fn = vi.fn(async () => ({ success: false, error: 'rate limited' }));
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
// Initial call happens immediately
await vi.advanceTimersByTimeAsync(0);
expect(fn).toHaveBeenCalledTimes(1);
// Retry 1 after 5s
await vi.advanceTimersByTimeAsync(5000);
expect(fn).toHaveBeenCalledTimes(2);
// Retry 2 after 10s
await vi.advanceTimersByTimeAsync(10000);
expect(fn).toHaveBeenCalledTimes(3);
// Retry 3 after 20s
await vi.advanceTimersByTimeAsync(20000);
expect(fn).toHaveBeenCalledTimes(4);
const result = await promise;
expect(result).toEqual({ success: false, error: 'rate limited' });
vi.useRealTimers();
});
it('stops retrying once the function succeeds', async () => {
vi.useFakeTimers();
const fn = vi.fn()
.mockResolvedValueOnce({ success: false, error: 'fail' })
.mockResolvedValueOnce({ success: true, value: 'ok' });
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
// Initial call fails
await vi.advanceTimersByTimeAsync(0);
expect(fn).toHaveBeenCalledTimes(1);
// Retry 1 after 5s — succeeds
await vi.advanceTimersByTimeAsync(5000);
expect(fn).toHaveBeenCalledTimes(2);
const result = await promise;
expect(result).toEqual({ success: true, value: 'ok' });
// Should not retry further
expect(fn).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('uses default 3 retries with 5s base delay', async () => {
vi.useFakeTimers();
const fn = vi.fn(async () => ({ success: false }));
const promise = retryWithBackoff(fn);
// Initial + 3 retries = 4 total calls
await vi.advanceTimersByTimeAsync(0); // initial
await vi.advanceTimersByTimeAsync(5000); // retry 1 (5s)
await vi.advanceTimersByTimeAsync(10000); // retry 2 (10s)
await vi.advanceTimersByTimeAsync(20000); // retry 3 (20s)
await promise;
expect(fn).toHaveBeenCalledTimes(4);
vi.useRealTimers();
});
it('applies exponential doubling: 5s, 10s, 20s', async () => {
vi.useFakeTimers();
const delays: number[] = [];
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const fn = vi.fn(async () => ({ success: false }));
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(10000);
await vi.advanceTimersByTimeAsync(20000);
await promise;
// Extract the delay values passed to setTimeout for our retries
const timeoutCalls = setTimeoutSpy.mock.calls
.filter(([, ms]) => typeof ms === 'number' && ms >= 5000)
.map(([, ms]) => ms);
expect(timeoutCalls).toContain(5000);
expect(timeoutCalls).toContain(10000);
expect(timeoutCalls).toContain(20000);
setTimeoutSpy.mockRestore();
vi.useRealTimers();
});
});

View File

@@ -24,4 +24,12 @@ describe('main/shared locale completeness', () => {
expect(localeKeys, `Locale ${locale} is missing or has extra keys`).toEqual(englishKeys);
}
});
it('includes a native-menu label for validate translations', () => {
expect((en as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((de as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((fr as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((itLocale as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((es as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
});
});

View File

@@ -3,6 +3,7 @@ import {
resolveSupportedRenderLanguage,
resolveRenderLanguageFromProjectPreferences,
translateRender,
getRenderTranslations,
} from '../../src/main/shared/i18n';
describe('render i18n', () => {
@@ -24,4 +25,17 @@ describe('render i18n', () => {
expect(translateRender('es', 'render.pagination.older')).toBe('más antiguo');
expect(translateRender('fr', 'missing.key')).toBe('missing.key');
});
it('returns full translation map for a language', () => {
const translations = getRenderTranslations('de');
expect(translations).toBeDefined();
expect(typeof translations).toBe('object');
expect(translations['render.pagination.newer']).toBe('neuer');
expect(translations['render.archive']).toBe('Archiv');
});
it('falls back to English for unsupported languages', () => {
const translations = getRenderTranslations('en');
expect(translations['render.archive']).toBe('Archive');
});
});

View File

@@ -12,6 +12,9 @@ const mockPostEngine: Record<string, ReturnType<typeof vi.fn>> = {
updatePost: vi.fn().mockResolvedValue(null),
deletePost: vi.fn().mockResolvedValue(true),
publishPost: vi.fn().mockResolvedValue(null),
publishPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'fr' }),
getPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'en', content: 'hello' }),
getPostTranslations: vi.fn().mockResolvedValue([{ id: 'tr1', language: 'en' }]),
discardChanges: vi.fn().mockResolvedValue(null),
hasPublishedVersion: vi.fn().mockResolvedValue(false),
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
@@ -431,6 +434,21 @@ describe('invokeMainProcessPythonApi', () => {
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('test', undefined);
});
it('routes posts.publishTranslation to postEngine.publishPostTranslation', async () => {
await invokeMainProcessPythonApi('posts.publishTranslation', { postId: 'p1', language: 'fr' });
expect(mockPostEngine.publishPostTranslation).toHaveBeenCalledWith('p1', 'fr');
});
it('routes posts.getTranslation to postEngine.getPostTranslation', async () => {
await invokeMainProcessPythonApi('posts.getTranslation', { postId: 'p1', language: 'en' });
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
});
it('routes posts.getTranslations to postEngine.getPostTranslations', async () => {
await invokeMainProcessPythonApi('posts.getTranslations', { postId: 'p1' });
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
});
it('handles null args gracefully (normalizes to empty record)', async () => {
await expect(
invokeMainProcessPythonApi('posts.get', null as unknown as Record<string, unknown>),

View File

@@ -1221,6 +1221,7 @@ describe('main bootstrap preview behavior', () => {
createPost,
setProjectContext: vi.fn(),
setSearchLanguage: vi.fn(),
setMainLanguage: vi.fn(),
}; }),
}));

View File

@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest';
import type { PostData } from '../../src/main/engine/PostEngine';
import {
applyLanguagePrefixToHtml,
PageRenderer,
type HtmlRewriteContext,
} from '../../src/main/engine/PageRenderer';
import {
buildSitemapAndFeeds,
buildMultiLanguageSitemap,
type GenerationPostIndexLike,
} from '../../src/main/engine/GenerationSitemapFeedService';
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-03-08T10:00:00.000Z');
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'default',
title: overrides.title ?? 'Test Post',
slug: overrides.slug ?? 'test-post',
excerpt: overrides.excerpt,
content: overrides.content ?? '# Test\n\nBody text',
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language ?? 'en',
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
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) {
for (const category of (post.categories ?? [])) {
postsByCategory.set(category, [...(postsByCategory.get(category) ?? []), post]);
}
for (const tag of (post.tags ?? [])) {
postsByTag.set(tag, [...(postsByTag.get(tag) ?? []), post]);
}
const year = post.createdAt.getFullYear();
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
const day = String(post.createdAt.getDate()).padStart(2, '0');
postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]);
postsByYearMonth.set(`${year}/${month}`, [...(postsByYearMonth.get(`${year}/${month}`) ?? []), post]);
postsByYearMonthDay.set(`${year}/${month}/${day}`, [...(postsByYearMonthDay.get(`${year}/${month}/${day}`) ?? []), post]);
}
return { postsByCategory, postsByTag, postsByYear, postsByYearMonth, postsByYearMonthDay };
}
describe('applyLanguagePrefixToHtml', () => {
it('prefixes internal hrefs with language prefix', () => {
const html = '<a href="/2025/03/08/my-post/">Post</a>';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
});
it('does not prefix media or asset paths', () => {
const html = '<img src="/media/2025/03/photo.jpg" /><link href="/assets/bds.css" />';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe(html);
});
it('does not double-prefix already prefixed hrefs', () => {
const html = '<a href="/de/2025/03/08/my-post/">Post</a>';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
});
it('prefixes root href', () => {
const html = '<a href="/">Home</a>';
const result = applyLanguagePrefixToHtml(html, '/fr');
expect(result).toBe('<a href="/fr/">Home</a>');
});
it('prefixes category and tag hrefs', () => {
const html = '<a href="/category/tech/">Tech</a><a href="/tag/js/">JS</a>';
const result = applyLanguagePrefixToHtml(html, '/es');
expect(result).toBe('<a href="/es/category/tech/">Tech</a><a href="/es/tag/js/">JS</a>');
});
it('returns html unchanged when prefix is empty', () => {
const html = '<a href="/some/path">Link</a>';
const result = applyLanguagePrefixToHtml(html, '');
expect(result).toBe(html);
});
it('prefixes pagination hrefs', () => {
const html = '<a href="/page/2">Next</a>';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe('<a href="/de/page/2">Next</a>');
});
it('handles both single and double quotes', () => {
const html = `<a href='/tag/foo/'>Foo</a><a href="/tag/bar/">Bar</a>`;
const result = applyLanguagePrefixToHtml(html, '/it');
expect(result).toBe(`<a href='/it/tag/foo/'>Foo</a><a href="/it/tag/bar/">Bar</a>`);
});
});
describe('data-language-prefix on html tag', () => {
it('renders data-language-prefix on post-list html tag', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'fr',
language_prefix: '/fr',
},
);
expect(html).toContain('data-language-prefix="/fr"');
});
it('renders empty data-language-prefix for main language', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'en',
language_prefix: '',
},
);
expect(html).toContain('data-language-prefix=""');
});
it('renders data-language-prefix on single-post html tag', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderSinglePost(
makePost({ content: 'Hello world' }),
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
page_title: 'Blog',
language: 'de',
language_prefix: '/de',
},
);
expect(html).toContain('data-language-prefix="/de"');
});
});
describe('Feed language filtering', () => {
it('includes feedLanguage element in RSS when specified', () => {
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Blog',
maxPostsPerPage: 10,
publishedPosts: posts,
publishedListPosts: posts,
postIndex: buildIndex(posts),
includeFeeds: true,
feedLanguage: 'de',
});
expect(result.rssXml).toContain('<language>de</language>');
});
it('includes xml:lang in Atom feed when feedLanguage is specified', () => {
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Blog',
maxPostsPerPage: 10,
publishedPosts: posts,
publishedListPosts: posts,
postIndex: buildIndex(posts),
includeFeeds: true,
feedLanguage: 'fr',
});
expect(result.atomXml).toContain('xml:lang="fr"');
});
it('omits language elements when feedLanguage is not specified', () => {
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Blog',
maxPostsPerPage: 10,
publishedPosts: posts,
publishedListPosts: posts,
postIndex: buildIndex(posts),
includeFeeds: true,
});
expect(result.rssXml).not.toContain('<language>');
expect(result.atomXml).not.toMatch(/<feed[^>]+xml:lang=/);
});
});
describe('buildMultiLanguageSitemap', () => {
it('generates hreflang links for translatable posts in all languages', () => {
const post = makePost({ id: '1', slug: 'hello', title: 'Hello' });
const postIndex = buildIndex([post]);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'de'],
translatablePosts: [post],
doNotTranslatePosts: [],
publishedListPosts: [post],
maxPostsPerPage: 10,
postIndex,
});
expect(sitemap).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
expect(sitemap).toContain('hreflang="en"');
expect(sitemap).toContain('hreflang="de"');
expect(sitemap).toContain('hreflang="x-default"');
// Main language post URL is unprefixed
expect(sitemap).toContain('href="https://example.com/2025/03/08/hello/"');
// Alternative language post URL is prefixed
expect(sitemap).toContain('href="https://example.com/de/2025/03/08/hello/"');
});
it('generates hreflang links only for main language for doNotTranslate posts', () => {
const dntPost = makePost({ id: '2', slug: 'private-note', title: 'Private' });
(dntPost as PostData & { doNotTranslate?: boolean }).doNotTranslate = true;
const postIndex = buildIndex([dntPost]);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'de', 'fr'],
translatablePosts: [],
doNotTranslatePosts: [dntPost],
publishedListPosts: [dntPost],
maxPostsPerPage: 10,
postIndex,
});
// The doNotTranslate post URL entry should exist
expect(sitemap).toContain('https://example.com/2025/03/08/private-note/');
// But it should NOT have de or fr hreflang links for this specific post
const postUrlBlock = sitemap.split('<url>').find((block) => block.includes('private-note'));
expect(postUrlBlock).toBeDefined();
expect(postUrlBlock).toContain('hreflang="en"');
expect(postUrlBlock).not.toContain('hreflang="de"');
expect(postUrlBlock).not.toContain('hreflang="fr"');
});
it('includes root page and pagination in all languages', () => {
const posts = Array.from({ length: 15 }, (_, i) =>
makePost({
id: `p-${i}`,
slug: `post-${i}`,
createdAt: new Date(`2025-03-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
}),
);
const postIndex = buildIndex(posts);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'de'],
translatablePosts: posts,
doNotTranslatePosts: [],
publishedListPosts: posts,
maxPostsPerPage: 10,
postIndex,
});
// Root page has both languages
expect(sitemap).toContain('https://example.com/');
expect(sitemap).toContain('https://example.com/de/');
// Pagination page 2
expect(sitemap).toContain('https://example.com/page/2');
expect(sitemap).toContain('https://example.com/de/page/2');
});
it('includes archive, category, and tag URLs in all languages', () => {
const post = makePost({
id: '1', slug: 'tagged', tags: ['javascript'], categories: ['tutorial'],
});
const postIndex = buildIndex([post]);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'fr'],
translatablePosts: [post],
doNotTranslatePosts: [],
publishedListPosts: [post],
maxPostsPerPage: 10,
postIndex,
});
expect(sitemap).toContain('https://example.com/category/tutorial');
expect(sitemap).toContain('https://example.com/fr/category/tutorial');
expect(sitemap).toContain('https://example.com/tag/javascript');
expect(sitemap).toContain('https://example.com/fr/tag/javascript');
expect(sitemap).toContain('https://example.com/2025/');
});
});
describe('Language switcher in templates', () => {
it('renders language switcher badges when blog_languages has multiple entries', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map([['test-post', '/2025/03/08/test-post']]),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: true,
routeKind: 'date',
archiveContext: { kind: 'root' },
basePathname: '/',
page_title: 'Blog',
language: 'en',
blog_languages: [
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true },
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: false },
],
current_language: 'en',
language_prefix: '',
},
);
expect(html).toContain('class="language-switcher"');
expect(html).toContain('class="language-switcher-badge language-switcher-badge-current"');
expect(html).toContain('🇬🇧');
expect(html).toContain('href="/de"');
expect(html).toContain('🇩🇪');
});
it('does not render language switcher when blog_languages has one or zero entries', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'en',
blog_languages: [{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true }],
current_language: 'en',
language_prefix: '',
},
);
expect(html).not.toContain('class="language-switcher"');
});
it('renders language switcher in single post template', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderSinglePost(
makePost({ content: 'Hello world' }),
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
page_title: 'Blog',
language: 'en',
blog_languages: [
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: false },
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: true },
],
current_language: 'de',
language_prefix: '/de',
},
);
expect(html).toContain('class="language-switcher"');
expect(html).toContain('aria-current="true"');
expect(html).toContain('🇩🇪');
});
});
describe('Per-language feed links in head', () => {
it('renders language-prefixed feed links in head partial', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'de',
language_prefix: '/de',
},
);
expect(html).toContain('href="/de/rss.xml"');
expect(html).toContain('href="/de/atom.xml"');
});
it('renders unprefixed feed links when no language prefix', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'en',
},
);
expect(html).toContain('href="/rss.xml"');
expect(html).toContain('href="/atom.xml"');
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { normalizeTranslatedMarkdownBody } from '../../src/main/engine/ai/tasks';
describe('normalizeTranslatedMarkdownBody', () => {
const sourceContent = '# Hello\n\nThis is the source content.';
it('strips English "content:" prefix', () => {
const input = 'content:\n\n# Hallo\n\nDies ist der Inhalt.';
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
'# Hallo\n\nDies ist der Inhalt.',
);
});
it('strips German "inhalt:" prefix', () => {
const input = 'Inhalt:\n\nDer Inhalt hier.';
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
'Der Inhalt hier.',
);
});
it('strips French "contenu:" prefix', () => {
const input = 'Contenu:\n\nLe contenu ici.';
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
'Le contenu ici.',
);
});
it('strips Italian "contenuto:" prefix', () => {
const input = 'Contenuto:\n\nIl contenuto qui.';
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
'Il contenuto qui.',
);
});
it('strips Spanish "contenido:" prefix', () => {
const input = 'Contenido:\n\nEl contenido aqui.';
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
'El contenido aqui.',
);
});
it('is case-insensitive', () => {
const input = 'CONTENT:\n\nBody text.';
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
'Body text.',
);
});
it('preserves content when source also has the prefix', () => {
const sourceWithPrefix = 'content:\n\nSource body.';
const input = 'Inhalt:\n\nTranslated body.';
expect(normalizeTranslatedMarkdownBody(input, sourceWithPrefix)).toBe(
'Inhalt:\n\nTranslated body.',
);
});
it('returns empty string for empty/whitespace input', () => {
expect(normalizeTranslatedMarkdownBody('', sourceContent)).toBe('');
expect(normalizeTranslatedMarkdownBody(' \n ', sourceContent)).toBe('');
});
it('returns content unchanged when no prefix is present', () => {
const input = '# Normal markdown\n\nBody text.';
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(input);
});
});