mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-07 00:28:39 +01:00
270 lines
9.6 KiB
TypeScript
270 lines
9.6 KiB
TypeScript
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']);
|
|
});
|
|
});
|