feat: translate all pages, UI messages and docs and fix SEO redirects and indexing

This commit is contained in:
alam00000 committed 2026-10-25 12:55:21 +05:30
1 parent 5e019de5ae
commit 4edb1718cc
3344 files changed
+415643 -4759

No files matched your search

+12 -7
View File
@@ -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') {
+269
View File
@@ -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&hellip;</1>')).toBe(
'unsupported entity &hellip;'
);
expect(validateTranslationPair('A &amp; B', 'X &amp; 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 &lt;img onerror=alert(1)&gt; <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']);
});
});
+206
View File
@@ -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 = [
'![Logo](/images/logo.png)',
'[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
View File
@@ -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');
});
});
+166
View File
@@ -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();
});
});
+39
View File
@@ -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);
});
});
+32
View File
@@ -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);
});
});
+74
View File
@@ -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 &quot;&lt;img src=x onerror=1&gt;&quot; to &lt;b&gt;');
});
it('only fills placeholders from the given values, not inherited ones', () => {
expect(fillPlaceholders('{{constructor}} {{n}}', { n: 2 })).toBe(
'{{constructor}} 2'
);
});
});
+158
View File
@@ -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([]);
});
});
+35
View File
@@ -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: [] });
});
});