mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-09 09:04:18 +01:00
feat: translate all pages, UI messages and docs and fix SEO redirects and indexing
This commit is contained in:
1 parent
5e019de5ae
commit
4edb1718cc
3344 files changed
+415643
-4759
No files matched your search
@@ -10,6 +10,15 @@ const mocks = vi.hoisted(() => ({
|
||||
downloadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const page = vi.hoisted(() => ({ ready: null as (() => void) | null }));
|
||||
|
||||
vi.mock('../js/i18n/i18n.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../js/i18n/i18n.js')>()),
|
||||
onPageReady: (callback: () => void) => {
|
||||
page.ready = callback;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/compress.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../js/utils/compress.js')>()),
|
||||
compressWithHyper: mocks.compressWithHyper,
|
||||
@@ -62,14 +71,10 @@ async function openPage(): Promise<void> {
|
||||
const parsed = new DOMParser().parseFromString(pageMarkup, 'text/html');
|
||||
document.body.replaceChildren(...Array.from(parsed.body.childNodes));
|
||||
vi.resetModules();
|
||||
const listen = vi.spyOn(document, 'addEventListener');
|
||||
page.ready = null;
|
||||
await import('../js/logic/compress-pdf-page');
|
||||
const handler = listen.mock.calls.find(
|
||||
([type]) => type === 'DOMContentLoaded'
|
||||
)?.[1];
|
||||
listen.mockRestore();
|
||||
if (typeof handler !== 'function') throw new Error('page did not start');
|
||||
handler(new Event('DOMContentLoaded'));
|
||||
if (!page.ready) throw new Error('page did not start');
|
||||
page.ready();
|
||||
}
|
||||
|
||||
function pdfFile(name: string, body = OPEN_BODY, type = 'application/pdf') {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseTranslation,
|
||||
processDocument,
|
||||
processPage,
|
||||
segmentId,
|
||||
validateTranslationPair,
|
||||
} from '../../scripts/content-segments.mjs';
|
||||
|
||||
interface Collected {
|
||||
id: string;
|
||||
markup: string;
|
||||
}
|
||||
|
||||
function parse(body: string, head = ''): Document {
|
||||
return new DOMParser().parseFromString(
|
||||
`<!doctype html><html><head>${head}</head><body>${body}</body></html>`,
|
||||
'text/html'
|
||||
);
|
||||
}
|
||||
|
||||
function collect(document: Document): Collected[] {
|
||||
const found: Collected[] = [];
|
||||
processDocument(document, {
|
||||
record: (id: string, markup: string): void => {
|
||||
found.push({ id, markup });
|
||||
},
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function translateWith(
|
||||
document: Document,
|
||||
dictionary: Record<string, string>
|
||||
): { translated: number; invalid: string[] } {
|
||||
const ids = new Map<string, string>();
|
||||
collect(parse(document.body.innerHTML, document.head.innerHTML)).forEach(
|
||||
(entry) => ids.set(entry.markup, entry.id)
|
||||
);
|
||||
const byId: Record<string, string> = {};
|
||||
for (const [markup, translation] of Object.entries(dictionary)) {
|
||||
const id = ids.get(markup) ?? segmentId(markup);
|
||||
byId[id] = translation;
|
||||
}
|
||||
return processDocument(document, {
|
||||
translate: (id: string) => byId[id] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('content segments', () => {
|
||||
it('turns inline elements into numbered placeholders', () => {
|
||||
const doc = parse('<p>Read <a href="/docs">the docs</a> now.</p>');
|
||||
expect(collect(doc).map((entry) => entry.markup)).toEqual([
|
||||
'Read <1>the docs</1> now.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps links intact and allows word order to change', () => {
|
||||
const doc = parse(
|
||||
'<p>Read <a href="/docs" class="x">the docs</a> now.</p>'
|
||||
);
|
||||
const result = translateWith(doc, {
|
||||
'Read <1>the docs</1> now.': 'Jetzt <1>die Doku</1> lesen.',
|
||||
});
|
||||
expect(result.translated).toBe(1);
|
||||
expect(doc.body.innerHTML).toBe(
|
||||
'<p>Jetzt <a href="/docs" class="x">die Doku</a> lesen.</p>'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats code and icons as untouched placeholders', () => {
|
||||
const doc = parse(
|
||||
'<p>Use <code>qpdf --check</code> first <i data-lucide="check"></i></p>'
|
||||
);
|
||||
expect(collect(doc).map((entry) => entry.markup)).toEqual([
|
||||
'Use <1/> first <2/>',
|
||||
]);
|
||||
translateWith(doc, { 'Use <1/> first <2/>': 'Zuerst <1/> nutzen <2/>' });
|
||||
expect(doc.body.innerHTML).toBe(
|
||||
'<p>Zuerst <code>qpdf --check</code> nutzen <i data-lucide="check"></i></p>'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a translation that loses a placeholder and keeps English', () => {
|
||||
const doc = parse('<p>Read <a href="/docs">the docs</a> now.</p>');
|
||||
const result = translateWith(doc, {
|
||||
'Read <1>the docs</1> now.': 'Jetzt die Doku lesen.',
|
||||
});
|
||||
expect(result.translated).toBe(0);
|
||||
expect(result.invalid).toHaveLength(1);
|
||||
expect(doc.body.innerHTML).toBe(
|
||||
'<p>Read <a href="/docs">the docs</a> now.</p>'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unbalanced or foreign tags', () => {
|
||||
const doc = parse('<p>One <b>two</b> three</p>');
|
||||
const elements = {
|
||||
1: doc.querySelector('b') as Element,
|
||||
};
|
||||
expect(parseTranslation('Eins <1>zwei</1> drei', elements)).not.toBeNull();
|
||||
expect(parseTranslation('Eins <1>zwei drei', elements)).toBeNull();
|
||||
expect(parseTranslation('Eins <2>zwei</2> drei', elements)).toBeNull();
|
||||
expect(parseTranslation('Eins <script>zwei</1> drei', elements)).toBeNull();
|
||||
expect(parseTranslation('Eins zwei drei', elements)).toBeNull();
|
||||
});
|
||||
|
||||
it('peels a wrapper that covers the whole block', () => {
|
||||
const doc = parse('<ul><li><a href="/a">Merge PDF</a></li></ul>');
|
||||
expect(collect(doc).map((entry) => entry.markup)).toEqual(['Merge PDF']);
|
||||
translateWith(doc, { 'Merge PDF': 'PDF zusammenführen' });
|
||||
expect(doc.body.innerHTML).toBe(
|
||||
'<ul><li><a href="/a">PDF zusammenführen</a></li></ul>'
|
||||
);
|
||||
});
|
||||
|
||||
it('translates alt, title, placeholder and aria-label', () => {
|
||||
const doc = parse(
|
||||
'<img alt="Company logo" src="/a.png"><input placeholder="Search tools" aria-label="Search box"><a href="/x" title="Open page">Go</a>'
|
||||
);
|
||||
translateWith(doc, {
|
||||
'Company logo': 'Firmenlogo',
|
||||
'Search tools': 'Werkzeuge suchen',
|
||||
'Search box': 'Suchfeld',
|
||||
'Open page': 'Seite öffnen',
|
||||
});
|
||||
expect(doc.querySelector('img')?.getAttribute('alt')).toBe('Firmenlogo');
|
||||
const input = doc.querySelector('input');
|
||||
expect(input?.getAttribute('placeholder')).toBe('Werkzeuge suchen');
|
||||
expect(input?.getAttribute('aria-label')).toBe('Suchfeld');
|
||||
expect(doc.querySelector('a')?.getAttribute('title')).toBe('Seite öffnen');
|
||||
});
|
||||
|
||||
it('leaves elements that use data-i18n to the existing system', () => {
|
||||
const doc = parse(
|
||||
'<h1 data-i18n="tools:x.name">Merge PDF</h1><p>Hello <span data-i18n="a.b">World</span> there</p>'
|
||||
);
|
||||
const markups = collect(doc).map((entry) => entry.markup);
|
||||
expect(markups).toEqual(['Hello <1/> there']);
|
||||
});
|
||||
|
||||
it('skips brand-only text and numbers', () => {
|
||||
const doc = parse(
|
||||
'<p>BentoPDF</p><p>PyMuPDF</p><p>2026</p><p>Kura Hyper Compress</p>'
|
||||
);
|
||||
expect(collect(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not change a document when there is no translation', () => {
|
||||
const html =
|
||||
'<h2>Title</h2><p>Some <b>bold</b> text, <a href="/x">link</a>.</p><button><svg></svg> Download</button>';
|
||||
const doc = parse(html);
|
||||
const before = doc.body.innerHTML;
|
||||
processDocument(doc, { translate: (): null => null });
|
||||
expect(doc.body.innerHTML).toBe(before);
|
||||
});
|
||||
|
||||
it('translates title and meta descriptions', () => {
|
||||
const doc = parse(
|
||||
'<p>Body</p>',
|
||||
'<title>Merge PDF Online</title><meta name="description" content="Combine files.">'
|
||||
);
|
||||
translateWith(doc, {
|
||||
'Merge PDF Online': 'PDF online zusammenführen',
|
||||
'Combine files.': 'Dateien kombinieren.',
|
||||
});
|
||||
expect(doc.title).toBe('PDF online zusammenführen');
|
||||
expect(
|
||||
doc.querySelector('meta[name="description"]')?.getAttribute('content')
|
||||
).toBe('Dateien kombinieren.');
|
||||
});
|
||||
|
||||
it('only touches the navbar and footer on English-only pages', () => {
|
||||
const doc = parse(
|
||||
'<nav><a href="/">Home page</a></nav><main><p>Legal text here.</p></main><footer><p>Contact us</p></footer>',
|
||||
'<title>Terms</title>'
|
||||
);
|
||||
const found: string[] = [];
|
||||
processPage(doc, 'terms', {
|
||||
record: (_id: string, markup: string): void => {
|
||||
found.push(markup);
|
||||
},
|
||||
});
|
||||
expect(found).toEqual(['Home page', 'Contact us']);
|
||||
});
|
||||
|
||||
it('rebuilds FAQ structured data from the translated page', () => {
|
||||
const doc = parse(
|
||||
'<details class="faq-d"><summary>Is it free?<i data-lucide="plus"></i></summary><p class="faq-d-a">Yes, always.</p></details><script type="application/ld+json">{"@type":"FAQPage","mainEntity":[]}</script>'
|
||||
);
|
||||
translateWith(doc, {
|
||||
'Is it free?<1/>': 'Ist es kostenlos?<1/>',
|
||||
'Yes, always.': 'Ja, immer.',
|
||||
});
|
||||
const data = JSON.parse(
|
||||
doc.querySelector('script[type="application/ld+json"]')?.textContent ??
|
||||
'{}'
|
||||
);
|
||||
expect(data.mainEntity[0].name).toBe('Ist es kostenlos?');
|
||||
expect(data.mainEntity[0].acceptedAnswer.text).toBe('Ja, immer.');
|
||||
});
|
||||
|
||||
it('validates source and translation pairs', () => {
|
||||
expect(validateTranslationPair('A <1>b</1>', 'X <1>y</1>')).toBeNull();
|
||||
expect(validateTranslationPair('A <1>b</1>', 'X y')).toBe(
|
||||
'placeholders differ'
|
||||
);
|
||||
expect(validateTranslationPair('A', '')).toBe('empty');
|
||||
expect(validateTranslationPair('A', 'B <b>c</b>')).toBe(
|
||||
'contains raw angle brackets'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps segment ids stable so stored translations stay valid', () => {
|
||||
expect(segmentId('How to deskew PDF online')).toBe('001b11bf2ae1');
|
||||
expect(segmentId('Merge PDF')).not.toBe(segmentId('Split PDF'));
|
||||
});
|
||||
|
||||
it('rejects translations that nest markers differently or use unknown entities', () => {
|
||||
expect(
|
||||
validateTranslationPair('A <1>b <2>c</2></1>', 'X <1>y</1> <2>z</2>')
|
||||
).toBe('placeholders nested differently');
|
||||
expect(validateTranslationPair('A <1>b</1>', 'X <1>y</1><2></2>')).toBe(
|
||||
'placeholders differ'
|
||||
);
|
||||
expect(validateTranslationPair('A <1>b</1>', 'X <1>y…</1>')).toBe(
|
||||
'unsupported entity …'
|
||||
);
|
||||
expect(validateTranslationPair('A & B', 'X & Y')).toBeNull();
|
||||
expect(validateTranslationPair('A <1/> b', '<1/> x')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps translated markup-like text as plain text', () => {
|
||||
const doc = parse('<p>Hello <b>world</b></p>');
|
||||
translateWith(doc, {
|
||||
'Hello <1>world</1>': 'Hallo <img onerror=alert(1)> <1>Welt</1>',
|
||||
});
|
||||
expect(doc.querySelector('img')).toBeNull();
|
||||
expect(doc.querySelector('p')?.textContent).toBe(
|
||||
'Hallo <img onerror=alert(1)> Welt'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the whole answer for FAQ structured data, not just the first paragraph', () => {
|
||||
const doc = parse(
|
||||
'<details class="faq-d"><summary>Why?</summary><p>Two reasons:</p><ul><li>One</li><li>Two</li></ul></details><script type="application/ld+json">{"@type":"FAQPage","mainEntity":[]}</script>'
|
||||
);
|
||||
translateWith(doc, {
|
||||
'Why?': 'Warum?',
|
||||
'Two reasons:': 'Zwei Gründe:',
|
||||
One: 'Eins',
|
||||
Two: 'Zwei',
|
||||
});
|
||||
const data = JSON.parse(
|
||||
doc.querySelector('script[type="application/ld+json"]')?.textContent ??
|
||||
'{}'
|
||||
);
|
||||
expect(data.mainEntity[0].acceptedAnswer.text).toBe(
|
||||
'Zwei Gründe: Eins Zwei'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips elements marked data-no-i18n', () => {
|
||||
const doc = parse(
|
||||
'<nav data-no-i18n aria-label="Breadcrumb"><a href="/">BentoPDF</a> Merge PDF</nav><p>Hello</p>'
|
||||
);
|
||||
expect(collect(doc).map((entry) => entry.markup)).toEqual(['Hello']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
anchorMap,
|
||||
brokenAnchors,
|
||||
quoteFrontmatter,
|
||||
removeGeneratedAnchors,
|
||||
translateAnchors,
|
||||
compareStructure,
|
||||
localizeLinks,
|
||||
summarize,
|
||||
} from '../../scripts/docs-translations.mjs';
|
||||
|
||||
describe('localizeLinks', () => {
|
||||
it('prefixes absolute docs links with the language', () => {
|
||||
expect(localizeLinks('See [Docker](/self-hosting/docker#env).', 'de')).toBe(
|
||||
'See [Docker](/de/self-hosting/docker#env).'
|
||||
);
|
||||
expect(localizeLinks('[Tools](/tools/)', 'zh-TW')).toBe(
|
||||
'[Tools](/zh-TW/tools/)'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves images, files, external links, anchors and relative links alone', () => {
|
||||
const text = [
|
||||
'',
|
||||
'[PDF](/files/sample.pdf)',
|
||||
'[Site](https://www.bentopdf.com/licensing)',
|
||||
'[Mail](mailto:a@b.co)',
|
||||
'[Top](#top)',
|
||||
'[Next](./merge-pdf)',
|
||||
'[Up](../index)',
|
||||
].join('\n');
|
||||
expect(localizeLinks(text, 'fr')).toBe(text);
|
||||
});
|
||||
|
||||
it('does not touch code blocks and is safe to run twice', () => {
|
||||
const text = '```md\n[x](/tools/)\n```\n\n[ok](/tools/)';
|
||||
const once = localizeLinks(text, 'es');
|
||||
expect(once).toBe('```md\n[x](/tools/)\n```\n\n[ok](/es/tools/)');
|
||||
expect(localizeLinks(once, 'es')).toBe(once);
|
||||
});
|
||||
|
||||
it('rewrites hero action links in frontmatter', () => {
|
||||
const text = '---\nhero:\n actions:\n - link: /getting-started\n---';
|
||||
expect(localizeLinks(text, 'ja')).toBe(
|
||||
'---\nhero:\n actions:\n - link: /ja/getting-started\n---'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStructure', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'title: Merge PDF',
|
||||
'description: Combine files.',
|
||||
'---',
|
||||
'',
|
||||
'# Merge PDF',
|
||||
'',
|
||||
'Read [the guide](/self-hosting/docker) first.',
|
||||
'',
|
||||
'```bash',
|
||||
'npm run build',
|
||||
'```',
|
||||
'',
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n');
|
||||
|
||||
it('accepts a faithful translation', () => {
|
||||
const translated = [
|
||||
'---',
|
||||
'title: PDF zusammenführen',
|
||||
'description: Dateien kombinieren.',
|
||||
'---',
|
||||
'',
|
||||
'# PDF zusammenführen',
|
||||
'',
|
||||
'Lesen Sie zuerst [die Anleitung](/self-hosting/docker).',
|
||||
'',
|
||||
'```bash',
|
||||
'npm run build',
|
||||
'```',
|
||||
'',
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n');
|
||||
expect(compareStructure(source, translated, 'de')).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags changed code, links, headings and em dashes', () => {
|
||||
const translated = source
|
||||
.replace('npm run build', 'npm ausführen')
|
||||
.replace('/self-hosting/docker', '/other')
|
||||
.replace('# Merge PDF', '## Merge PDF')
|
||||
.replace('Read', 'Lesen —');
|
||||
const problems = compareStructure(source, translated, 'de');
|
||||
expect(problems).toContain('code blocks differ');
|
||||
expect(problems).toContain('link targets differ');
|
||||
expect(problems).toContain('heading levels differ');
|
||||
expect(problems).toContain('contains an em dash');
|
||||
});
|
||||
|
||||
it('treats localized absolute links as equal to the English ones', () => {
|
||||
const translated = source.replace(
|
||||
'/self-hosting/docker',
|
||||
'/de/self-hosting/docker'
|
||||
);
|
||||
expect(compareStructure(source, translated, 'de')).not.toContain(
|
||||
'link targets differ'
|
||||
);
|
||||
});
|
||||
|
||||
it('summarizes structure', () => {
|
||||
const summary = summarize(source);
|
||||
expect(summary.headings).toEqual([1]);
|
||||
expect(summary.fences).toHaveLength(1);
|
||||
expect(summary.tables).toEqual([3]);
|
||||
expect(summary.frontmatterKeys).toEqual(['title', 'description']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchors', () => {
|
||||
const source =
|
||||
'# Docker\n\n## Disabling Specific Tools\n\n### Disabling Specific Tools\n\n### 404 on Refresh\n\nSee [refresh](#_404-on-refresh) and [tools](/self-hosting/nginx#_502-bad-gateway).\n';
|
||||
const translated =
|
||||
'# Docker {#docker}\n\n## Werkzeuge deaktivieren {#disabling-specific-tools}\n\n### Werkzeuge deaktivieren\n\n### 404 beim Neuladen {#\\_404-on-refresh}\n\nSiehe [Neuladen](#_404-on-refresh) und [Werkzeuge](/de/self-hosting/nginx#_502-bad-gateway).\n';
|
||||
|
||||
it('removes generated heading ids, including ones Prettier escaped', () => {
|
||||
const cleaned = removeGeneratedAnchors(source, translated);
|
||||
expect(cleaned).not.toContain('{#');
|
||||
expect(cleaned).toContain('### 404 beim Neuladen\n');
|
||||
});
|
||||
|
||||
it('keeps explicit ids that the English page defines', () => {
|
||||
expect(
|
||||
removeGeneratedAnchors('## One {#custom}\n', '## Eins {#old}\n')
|
||||
).toBe('## Eins {#custom}\n');
|
||||
});
|
||||
|
||||
it('maps English heading ids to the translated ones, like VitePress', () => {
|
||||
const cleaned = removeGeneratedAnchors(source, translated);
|
||||
const { map } = anchorMap(source, cleaned);
|
||||
expect(map.get('disabling-specific-tools')).toBe('werkzeuge-deaktivieren');
|
||||
expect(map.get('disabling-specific-tools-1')).toBe(
|
||||
'werkzeuge-deaktivieren-1'
|
||||
);
|
||||
expect(map.get('_404-on-refresh')).toBe('_404-beim-neuladen');
|
||||
});
|
||||
|
||||
it('rewrites anchor links on the same page and on other pages', () => {
|
||||
const cleaned = removeGeneratedAnchors(source, translated);
|
||||
const nginxSource = '## 502 Bad Gateway\n';
|
||||
const nginxTranslated = '## 502 Fehlerhaftes Gateway\n';
|
||||
const anchors = new Map([
|
||||
['self-hosting/vercel.md', anchorMap(source, cleaned)],
|
||||
['self-hosting/nginx.md', anchorMap(nginxSource, nginxTranslated)],
|
||||
]);
|
||||
const result = translateAnchors(
|
||||
cleaned,
|
||||
'self-hosting/vercel.md',
|
||||
'de',
|
||||
anchors
|
||||
);
|
||||
expect(result).toContain('[Neuladen](#_404-beim-neuladen)');
|
||||
expect(result).toContain(
|
||||
'[Werkzeuge](/de/self-hosting/nginx#_502-fehlerhaftes-gateway)'
|
||||
);
|
||||
expect(
|
||||
translateAnchors(result, 'self-hosting/vercel.md', 'de', anchors)
|
||||
).toBe(result);
|
||||
expect(
|
||||
brokenAnchors(result, 'self-hosting/vercel.md', 'de', anchors)
|
||||
).toEqual([]);
|
||||
expect(
|
||||
brokenAnchors('[x](#missing)', 'self-hosting/vercel.md', 'de', anchors)
|
||||
).toEqual(['#missing']);
|
||||
});
|
||||
|
||||
it('builds ids the same way VitePress does for code and punctuation', () => {
|
||||
const { map } = anchorMap(
|
||||
'## Step 3b: Response Headers Policy (Required)\n\n## `ROBOTS_NOINDEX` flag\n',
|
||||
'## Schritt 3b: Antwort (Pflicht)\n\n## `ROBOTS_NOINDEX`-Schalter\n'
|
||||
);
|
||||
expect(map.get('step-3b-response-headers-policy-required')).toBe(
|
||||
'schritt-3b-antwort-pflicht'
|
||||
);
|
||||
expect(map.get('robots-noindex-flag')).toBe('robots-noindex-schalter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('quoteFrontmatter', () => {
|
||||
it('quotes values that YAML would misread and leaves the rest alone', () => {
|
||||
const input =
|
||||
'---\ntitle: Merge PDF\ndescription: Entfernen: Autor, Titel\nnote: "already quoted: yes"\n---\n# Body: text\n';
|
||||
expect(quoteFrontmatter(input)).toBe(
|
||||
'---\ntitle: Merge PDF\ndescription: "Entfernen: Autor, Titel"\nnote: "already quoted: yes"\n---\n# Body: text\n'
|
||||
);
|
||||
expect(quoteFrontmatter(quoteFrontmatter(input))).toBe(
|
||||
quoteFrontmatter(input)
|
||||
);
|
||||
});
|
||||
});
|
||||
+78
-61
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { getLanguageFromUrl } from '@/js/i18n/i18n';
|
||||
import {
|
||||
changeLanguage,
|
||||
getLanguageFromUrl,
|
||||
LANGUAGE_CHOICE_KEY,
|
||||
} from '@/js/i18n/i18n';
|
||||
|
||||
describe('getLanguageFromUrl', () => {
|
||||
const originalLocation = window.location;
|
||||
@@ -51,79 +55,92 @@ describe('getLanguageFromUrl', () => {
|
||||
|
||||
it('should prioritize URL path over localStorage', () => {
|
||||
window.location.pathname = '/fr/';
|
||||
localStorage.setItem('i18nextLng', 'es');
|
||||
localStorage.setItem(LANGUAGE_CHOICE_KEY, 'es');
|
||||
expect(getLanguageFromUrl()).toBe('fr');
|
||||
});
|
||||
|
||||
it('should return language from localStorage if URL has no language', () => {
|
||||
it('should use English for unprefixed URLs whatever the browser or storage says', () => {
|
||||
window.location.pathname = '/about';
|
||||
localStorage.setItem('i18nextLng', 'it');
|
||||
expect(getLanguageFromUrl()).toBe('it');
|
||||
});
|
||||
|
||||
it('should return exact match from navigator.languages', () => {
|
||||
window.location.pathname = '/';
|
||||
localStorage.setItem('bentopdf-language', 'de');
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
value: ['zh-TW', 'en-US', 'en'],
|
||||
value: ['fr-CA', 'de-DE'],
|
||||
configurable: true,
|
||||
});
|
||||
vi.stubEnv('VITE_DEFAULT_LANGUAGE', 'vi');
|
||||
expect(getLanguageFromUrl()).toBe('en');
|
||||
});
|
||||
|
||||
it('should match zh-TW from the URL', () => {
|
||||
window.location.pathname = '/zh-TW/merge-pdf';
|
||||
expect(getLanguageFromUrl()).toBe('zh-TW');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return primary language match from navigator.languages', () => {
|
||||
window.location.pathname = '/';
|
||||
// 'de-AT' is not in supportedLanguages, but we should match its primary 'de'
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
value: ['de-AT', 'en-US', 'en'],
|
||||
configurable: true,
|
||||
});
|
||||
expect(getLanguageFromUrl()).toBe('de');
|
||||
});
|
||||
describe('changeLanguage', () => {
|
||||
const originalLocation = window.location;
|
||||
let target = '';
|
||||
|
||||
it('should return first matched language from navigator.languages', () => {
|
||||
window.location.pathname = '/';
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
value: ['fr-CA', 'de-DE', 'en'],
|
||||
configurable: true,
|
||||
});
|
||||
expect(getLanguageFromUrl()).toBe('fr');
|
||||
});
|
||||
|
||||
it('should ignore unsupported languages in navigator.languages', () => {
|
||||
window.location.pathname = '/';
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
value: ['xx-XX', 'es-ES'],
|
||||
configurable: true,
|
||||
});
|
||||
expect(getLanguageFromUrl()).toBe('es');
|
||||
});
|
||||
|
||||
it('should fallback to env variable if no earlier match', () => {
|
||||
window.location.pathname = '/';
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
value: ['xx'],
|
||||
configurable: true,
|
||||
}); // unsupported
|
||||
vi.stubEnv('VITE_DEFAULT_LANGUAGE', 'vi');
|
||||
expect(getLanguageFromUrl()).toBe('vi');
|
||||
});
|
||||
|
||||
it('should fallback to en if everything else fails', () => {
|
||||
window.location.pathname = '/';
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
value: [],
|
||||
configurable: true,
|
||||
});
|
||||
vi.stubEnv('VITE_DEFAULT_LANGUAGE', '');
|
||||
expect(getLanguageFromUrl()).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle missing navigator object gracefully', () => {
|
||||
window.location.pathname = '/';
|
||||
Object.defineProperty(window, 'navigator', {
|
||||
value: undefined,
|
||||
function at(pathname: string, search = '', hash = '') {
|
||||
target = '';
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
set href(value: string) {
|
||||
target = value;
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(getLanguageFromUrl()).toBe('en');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.stubEnv('BASE_URL', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('moves to the same page in the chosen language and remembers it', () => {
|
||||
at('/merge-pdf', '?a=1', '#faq');
|
||||
changeLanguage('de');
|
||||
expect(target).toBe('/de/merge-pdf?a=1#faq');
|
||||
expect(localStorage.getItem(LANGUAGE_CHOICE_KEY)).toBe('de');
|
||||
});
|
||||
|
||||
it('swaps one language for another and drops the prefix for English', () => {
|
||||
at('/de/merge-pdf');
|
||||
changeLanguage('fr');
|
||||
expect(target).toBe('/fr/merge-pdf');
|
||||
at('/fr/merge-pdf');
|
||||
changeLanguage('en');
|
||||
expect(target).toBe('/merge-pdf');
|
||||
expect(localStorage.getItem(LANGUAGE_CHOICE_KEY)).toBe('en');
|
||||
});
|
||||
|
||||
it('handles the home page and zh-TW', () => {
|
||||
at('/');
|
||||
changeLanguage('zh-TW');
|
||||
expect(target).toBe('/zh-TW');
|
||||
at('/zh-TW/');
|
||||
changeLanguage('en');
|
||||
expect(target).toBe('/');
|
||||
});
|
||||
|
||||
it('keeps a base path', () => {
|
||||
vi.stubEnv('BASE_URL', '/pdf/');
|
||||
at('/pdf/de/merge-pdf');
|
||||
changeLanguage('ja');
|
||||
expect(target).toBe('/pdf/ja/merge-pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '../js/language-redirect.js'),
|
||||
'utf-8'
|
||||
);
|
||||
const LANGUAGES = 'ar,de,fr,ja,zh,zh-TW';
|
||||
|
||||
interface Visit {
|
||||
path: string;
|
||||
browser?: string[];
|
||||
choice?: string | null;
|
||||
base?: string;
|
||||
defaultLanguage?: string;
|
||||
storageThrows?: boolean;
|
||||
referrer?: string;
|
||||
hash?: string;
|
||||
withoutScript?: boolean;
|
||||
}
|
||||
|
||||
function visit({
|
||||
path: pathname,
|
||||
browser = ['en-US'],
|
||||
choice = null,
|
||||
base = '/',
|
||||
defaultLanguage = 'en',
|
||||
storageThrows = false,
|
||||
referrer = '',
|
||||
hash = '',
|
||||
withoutScript = false,
|
||||
}: Visit): string | null {
|
||||
let redirect: string | null = null;
|
||||
const attributes: Record<string, string> = {
|
||||
'data-base': base,
|
||||
'data-languages': LANGUAGES,
|
||||
'data-default-language': defaultLanguage,
|
||||
};
|
||||
const document = {
|
||||
referrer,
|
||||
currentScript: withoutScript
|
||||
? null
|
||||
: { getAttribute: (name: string) => attributes[name] ?? null },
|
||||
};
|
||||
const window = {
|
||||
location: {
|
||||
pathname,
|
||||
origin: 'https://www.bentopdf.com',
|
||||
search: '?q=1',
|
||||
hash,
|
||||
replace: (url: string) => {
|
||||
redirect = url;
|
||||
},
|
||||
},
|
||||
navigator: { languages: browser, language: browser[0] },
|
||||
localStorage: {
|
||||
getItem: () => {
|
||||
if (storageThrows) throw new Error('blocked');
|
||||
return choice;
|
||||
},
|
||||
},
|
||||
};
|
||||
new Function('document', 'window', source)(document, window);
|
||||
return redirect;
|
||||
}
|
||||
|
||||
describe('language-redirect.js', () => {
|
||||
it('sends first-time visitors of the home page to their browser language', () => {
|
||||
expect(visit({ path: '/', browser: ['de-AT', 'en'] })).toBe('/de?q=1');
|
||||
expect(visit({ path: '/index.html', browser: ['fr-CA'] })).toBe('/fr?q=1');
|
||||
});
|
||||
|
||||
it('keeps English browsers and unsupported languages on English', () => {
|
||||
expect(visit({ path: '/', browser: ['en-GB', 'de'] })).toBeNull();
|
||||
expect(visit({ path: '/', browser: ['xx-XX'] })).toBeNull();
|
||||
});
|
||||
|
||||
it('maps Chinese variants to the right script', () => {
|
||||
expect(visit({ path: '/', browser: ['zh-HK'] })).toBe('/zh-TW?q=1');
|
||||
expect(visit({ path: '/', browser: ['zh-Hant-TW'] })).toBe('/zh-TW?q=1');
|
||||
expect(visit({ path: '/', browser: ['zh-CN'] })).toBe('/zh?q=1');
|
||||
});
|
||||
|
||||
it('never redirects deep links on browser language alone', () => {
|
||||
expect(visit({ path: '/merge-pdf', browser: ['de-DE'] })).toBeNull();
|
||||
});
|
||||
|
||||
it('follows an explicit choice on any unprefixed page', () => {
|
||||
expect(visit({ path: '/merge-pdf', choice: 'ja' })).toBe(
|
||||
'/ja/merge-pdf?q=1'
|
||||
);
|
||||
expect(visit({ path: '/', browser: ['de-DE'], choice: 'en' })).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves language URLs, the blog and the docs alone', () => {
|
||||
expect(visit({ path: '/fr/merge-pdf', choice: 'de' })).toBeNull();
|
||||
expect(visit({ path: '/blog/some-post', choice: 'de' })).toBeNull();
|
||||
expect(visit({ path: '/docs/tools', choice: 'de' })).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the self-hosted default language when the browser has no match', () => {
|
||||
expect(visit({ path: '/', browser: ['xx'], defaultLanguage: 'fr' })).toBe(
|
||||
'/fr?q=1'
|
||||
);
|
||||
expect(
|
||||
visit({ path: '/', browser: ['en-US'], defaultLanguage: 'fr' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('respects a base path', () => {
|
||||
expect(visit({ path: '/pdf/', browser: ['de'], base: '/pdf/' })).toBe(
|
||||
'/pdf/de?q=1'
|
||||
);
|
||||
expect(
|
||||
visit({ path: '/pdf/de/', browser: ['de'], base: '/pdf/' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not auto-detect when the visitor came from another page of the site', () => {
|
||||
expect(
|
||||
visit({
|
||||
path: '/',
|
||||
browser: ['de-DE'],
|
||||
referrer: 'https://www.bentopdf.com/merge-pdf',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
visit({
|
||||
path: '/',
|
||||
browser: ['de-DE'],
|
||||
referrer: 'https://www.google.com/',
|
||||
})
|
||||
).toBe('/de?q=1');
|
||||
});
|
||||
|
||||
it('treats the base path without a trailing slash as the home page', () => {
|
||||
expect(visit({ path: '/pdf', browser: ['de'], base: '/pdf/' })).toBe(
|
||||
'/pdf/de?q=1'
|
||||
);
|
||||
expect(visit({ path: '/pdf/merge-pdf', choice: 'fr', base: '/pdf/' })).toBe(
|
||||
'/pdf/fr/merge-pdf?q=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('survives blocked storage', () => {
|
||||
expect(visit({ path: '/', browser: ['de'], storageThrows: true })).toBe(
|
||||
'/de?q=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a stored choice that is not a supported language', () => {
|
||||
expect(visit({ path: '/merge-pdf', choice: 'xx' })).toBeNull();
|
||||
expect(visit({ path: '/', browser: ['en'], choice: 'xx' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the hash and does nothing without its script element', () => {
|
||||
expect(visit({ path: '/merge-pdf', choice: 'de', hash: '#faq' })).toBe(
|
||||
'/de/merge-pdf?q=1#faq'
|
||||
);
|
||||
expect(
|
||||
visit({ path: '/', browser: ['de'], withoutScript: true })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { onPageReady } from '../js/i18n/i18n';
|
||||
|
||||
describe('onPageReady', () => {
|
||||
it('runs the callback once translations and the DOM are ready', async () => {
|
||||
const callback = vi.fn();
|
||||
onPageReady(callback);
|
||||
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('runs every registered callback exactly once', async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
onPageReady(first);
|
||||
onPageReady(second);
|
||||
await vi.waitFor(() => {
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onPageReady file drops', () => {
|
||||
it('keeps the browser from opening a dropped file until the page is ready', async () => {
|
||||
let ready = false;
|
||||
onPageReady(() => {
|
||||
ready = true;
|
||||
});
|
||||
const early = new Event('drop', { cancelable: true });
|
||||
window.dispatchEvent(early);
|
||||
expect(ready).toBe(false);
|
||||
expect(early.defaultPrevented).toBe(true);
|
||||
|
||||
await vi.waitFor(() => expect(ready).toBe(true));
|
||||
const late = new Event('drop', { cancelable: true });
|
||||
window.dispatchEvent(late);
|
||||
expect(late.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTranslationChecker } from '../../scripts/page-translation.mjs';
|
||||
|
||||
describe('createTranslationChecker', () => {
|
||||
const isTranslated = createTranslationChecker(
|
||||
{ de: { mergePdf: { name: 'PDF zusammenführen' } } },
|
||||
(page: string) =>
|
||||
page.replace(/-([a-z])/g, (_: string, letter: string) =>
|
||||
letter.toUpperCase()
|
||||
)
|
||||
);
|
||||
|
||||
it('always treats English and the home page as translated', () => {
|
||||
expect(isTranslated('terms', 'en')).toBe(true);
|
||||
expect(isTranslated('index', 'xx')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the tools.json entry when one exists', () => {
|
||||
expect(isTranslated('merge-pdf', 'de')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps English-only and unknown pages untranslated', () => {
|
||||
expect(isTranslated('terms', 'de')).toBe(false);
|
||||
expect(isTranslated('no-such-page', 'de')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a page as translated once enough of its text is', () => {
|
||||
expect(isTranslated('about', 'de')).toBe(true);
|
||||
expect(isTranslated('about', 'xx')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import i18next from 'i18next';
|
||||
import { trHtml, trText } from '../js/i18n/i18n';
|
||||
import { fillPlaceholders } from '../js/i18n/placeholders';
|
||||
|
||||
describe('trText', () => {
|
||||
beforeAll(async () => {
|
||||
await i18next.init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
resources: {
|
||||
en: { ui: {} },
|
||||
de: {
|
||||
ui: {
|
||||
'Adding {{n}} blank pages...': 'Füge {{n}} leere Seiten hinzu...',
|
||||
'Open the file. Then: save it.': 'Datei öffnen. Dann speichern.',
|
||||
'Empty one': '',
|
||||
},
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns English text with placeholders filled in', async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
expect(trText('Adding {{n}} blank pages...', { n: 3 })).toBe(
|
||||
'Adding 3 blank pages...'
|
||||
);
|
||||
expect(trText('Plain text')).toBe('Plain text');
|
||||
});
|
||||
|
||||
it('uses the translation for the current language', async () => {
|
||||
await i18next.changeLanguage('de');
|
||||
expect(trText('Adding {{n}} blank pages...', { n: 3 })).toBe(
|
||||
'Füge 3 leere Seiten hinzu...'
|
||||
);
|
||||
});
|
||||
|
||||
it('finds keys that contain dots and colons', async () => {
|
||||
await i18next.changeLanguage('de');
|
||||
expect(trText('Open the file. Then: save it.')).toBe(
|
||||
'Datei öffnen. Dann speichern.'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to English when a translation is missing or empty', async () => {
|
||||
await i18next.changeLanguage('de');
|
||||
expect(trText('Not translated {{x}}', { x: 'yet' })).toBe(
|
||||
'Not translated yet'
|
||||
);
|
||||
expect(trText('Empty one')).toBe('Empty one');
|
||||
});
|
||||
|
||||
it('keeps unknown placeholders visible instead of dropping them', async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
expect(trText('Hello {{name}}', { other: 1 })).toBe('Hello {{name}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trHtml and placeholders', () => {
|
||||
it('escapes the template and the values for HTML', async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
expect(
|
||||
trHtml('Rename "{{name}}" to <b>', { name: '<img src=x onerror=1>' })
|
||||
).toBe('Rename "<img src=x onerror=1>" to <b>');
|
||||
});
|
||||
|
||||
it('only fills placeholders from the given values, not inherited ones', () => {
|
||||
expect(fillPlaceholders('{{constructor}} {{n}}', { n: 2 })).toBe(
|
||||
'{{constructor}} 2'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { reviewTranslation } from '../../scripts/translations.mjs';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const LOCALES = path.join(ROOT, 'public/locales');
|
||||
|
||||
interface SourceEntry {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function readJson<T>(file: string, fallback: T): T {
|
||||
return fs.existsSync(file)
|
||||
? (JSON.parse(fs.readFileSync(file, 'utf-8')) as T)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
const languages = fs
|
||||
.readdirSync(LOCALES)
|
||||
.filter((entry) => entry !== 'en')
|
||||
.filter((entry) => fs.statSync(path.join(LOCALES, entry)).isDirectory());
|
||||
|
||||
const pageSource = readJson<Record<string, SourceEntry>>(
|
||||
path.join(ROOT, 'translations/source.json'),
|
||||
{}
|
||||
);
|
||||
const uiSource = readJson<Record<string, unknown>>(
|
||||
path.join(ROOT, 'translations/ui-source.json'),
|
||||
{}
|
||||
);
|
||||
|
||||
const variables = (text: string) =>
|
||||
(text.match(/\{\{\w+\}\}/g) ?? []).sort().join(',');
|
||||
|
||||
describe('translated page content', () => {
|
||||
it.each(languages)('%s keeps placeholders and style rules', (lang) => {
|
||||
const content = readJson<Record<string, string>>(
|
||||
path.join(ROOT, 'translations', lang, 'content.json'),
|
||||
{}
|
||||
);
|
||||
const problems: string[] = [];
|
||||
for (const [id, translation] of Object.entries(content)) {
|
||||
const source = pageSource[id];
|
||||
if (!source) continue;
|
||||
const review = reviewTranslation(lang, source.text, translation);
|
||||
if (review && review.level === 'error') {
|
||||
problems.push(`${id}: ${review.reason}`);
|
||||
}
|
||||
}
|
||||
expect(problems.slice(0, 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translated interface messages', () => {
|
||||
it.each(languages)('%s keeps variables and has no markup', (lang) => {
|
||||
const messages = readJson<Record<string, string>>(
|
||||
path.join(LOCALES, lang, 'ui.json'),
|
||||
{}
|
||||
);
|
||||
const problems: string[] = [];
|
||||
for (const [text, translation] of Object.entries(messages)) {
|
||||
if (!(text in uiSource)) continue;
|
||||
if (variables(text) !== variables(translation)) {
|
||||
problems.push(`${text}: variables differ`);
|
||||
}
|
||||
if (/<\/?[a-z][^>]*>/i.test(translation)) {
|
||||
problems.push(`${text}: contains HTML`);
|
||||
}
|
||||
if (translation.includes('—')) {
|
||||
problems.push(`${text}: contains an em dash`);
|
||||
}
|
||||
}
|
||||
expect(problems.slice(0, 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs labels', () => {
|
||||
it.each(languages)('%s only uses known labels', (lang) => {
|
||||
const source = readJson<string[]>(
|
||||
path.join(ROOT, 'docs/.vitepress/i18n/source.json'),
|
||||
[]
|
||||
);
|
||||
const labels = readJson<Record<string, string>>(
|
||||
path.join(ROOT, 'docs/.vitepress/i18n', `${lang}.json`),
|
||||
{}
|
||||
);
|
||||
const unknown = Object.keys(labels).filter((key) => !source.includes(key));
|
||||
expect(unknown).toEqual([]);
|
||||
const bad = source.filter(
|
||||
(key) => !labels[key] || !labels[key].trim() || labels[key].includes('—')
|
||||
);
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translation files', () => {
|
||||
it.each(languages)('%s has every translation file', (lang) => {
|
||||
const files = [
|
||||
path.join(ROOT, 'translations', lang, 'content.json'),
|
||||
path.join(LOCALES, lang, 'ui.json'),
|
||||
path.join(LOCALES, lang, 'common.json'),
|
||||
path.join(LOCALES, lang, 'tools.json'),
|
||||
path.join(ROOT, 'docs/.vitepress/i18n', `${lang}.json`),
|
||||
];
|
||||
expect(files.filter((file) => !fs.existsSync(file))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const flattenKeys = (
|
||||
value: unknown,
|
||||
prefix = '',
|
||||
out: Record<string, string> = {}
|
||||
): Record<string, string> => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
flattenKeys(child, prefix ? `${prefix}.${key}` : key, out);
|
||||
}
|
||||
} else if (typeof value === 'string') {
|
||||
out[prefix] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('locale files', () => {
|
||||
const english = Object.fromEntries(
|
||||
['common', 'tools'].map((ns) => [
|
||||
ns,
|
||||
flattenKeys(readJson(path.join(LOCALES, 'en', `${ns}.json`), {})),
|
||||
])
|
||||
);
|
||||
it.each(languages)('%s has every key that English has', (lang) => {
|
||||
const missing: string[] = [];
|
||||
for (const [ns, keys] of Object.entries(english)) {
|
||||
const translated = flattenKeys(
|
||||
readJson(path.join(LOCALES, lang, `${ns}.json`), {})
|
||||
);
|
||||
for (const [key, english] of Object.entries(keys)) {
|
||||
const value = translated[key];
|
||||
if (!value) {
|
||||
missing.push(`${ns}:${key}`);
|
||||
continue;
|
||||
}
|
||||
if (variables(english) !== variables(value)) {
|
||||
missing.push(`${ns}:${key} variables differ`);
|
||||
}
|
||||
const tags = (text: string) =>
|
||||
(text.match(/<\/?[a-z][^>]*>/gi) ?? []).length;
|
||||
if (tags(english) !== tags(value)) {
|
||||
missing.push(`${ns}:${key} HTML differs`);
|
||||
}
|
||||
if (value.includes('—')) missing.push(`${ns}:${key} em dash`);
|
||||
}
|
||||
}
|
||||
expect(missing.slice(0, 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { scanRawUiText } from '../../scripts/raw-ui-text-scan.mjs';
|
||||
import { extractUiStrings } from '../../scripts/extract-ui-strings.mjs';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
interface RawUiSite {
|
||||
file: string;
|
||||
line: number;
|
||||
kind: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
describe('UI text goes through i18n', () => {
|
||||
it('has no raw English in UI sinks', () => {
|
||||
const sites = scanRawUiText() as RawUiSite[];
|
||||
const report = sites
|
||||
.map((site) => `${site.file}:${site.line} [${site.kind}] ${site.text}`)
|
||||
.join('\n');
|
||||
expect(report).toBe('');
|
||||
});
|
||||
|
||||
it('keeps translations/ui-source.json in sync with the code', () => {
|
||||
const { result } = extractUiStrings();
|
||||
const committed = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, 'translations/ui-source.json'), 'utf-8')
|
||||
) as Record<string, unknown>;
|
||||
const missing = Object.keys(result).filter((text) => !(text in committed));
|
||||
const stale = Object.keys(committed).filter((text) => !(text in result));
|
||||
expect({ missing, stale }).toEqual({ missing: [], stale: [] });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user