Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c7705b7083
commit
e15b2517b1
51 files changed
+3045
-219
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fingerprint, entries, progress, setText, confirmText, setPageValue, FIELDS } from '../public/assets/translate.js';
|
||||
import { EditorHistory } from '../public/assets/editor-model.js';
|
||||
|
||||
const page = () => ({
|
||||
schemaVersion: 26, title: 'Leistungen', description: 'Was wir tun',
|
||||
blocks: [{ id: 's1', type: 'section', children: [
|
||||
{ id: 'h1', type: 'heading', props: { text: 'Leistungen', level: 1 } },
|
||||
{ id: 'b1', type: 'button', props: { text: 'Kontakt', href: '/kontakt' } },
|
||||
{ id: 'i1', type: 'image', props: { src: '/x.svg', alt: '', decorative: true, width: 1, height: 1 } },
|
||||
{ id: 'q1', type: 'quote', props: { text: 'Sehr gut', author: 'Anna', role: '', rating: 5 } },
|
||||
] }],
|
||||
});
|
||||
|
||||
test('the fingerprint matches hash("fnv1a32") in PHP, also for umlauts and markup', () => {
|
||||
// Werte aus PHP 8.4: hash('fnv1a32', …)
|
||||
assert.equal(fingerprint('Leistungen'), '16a48bdd');
|
||||
assert.equal(fingerprint('Über uns – <b>ß</b>'), '80c36c2f');
|
||||
});
|
||||
|
||||
test('the translatable fields mirror Translations::FIELDS on the server', () => {
|
||||
const php = readFileSync(new URL('../src/Content/Translations.php', import.meta.url), 'utf8');
|
||||
const server = Object.fromEntries([...php.matchAll(/'([a-z]+)' => \[((?:'[a-zA-Z]+'(?:, )?)+)\],/g)].map(([, type, list]) => [type, [...list.matchAll(/'([a-zA-Z]+)'/g)].map(match => match[1])]));
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(FIELDS).map(([type, fields]) => [type, fields.map(field => field.prop)])), server);
|
||||
});
|
||||
|
||||
test('translating writes only into translations, notes the original and can be undone', () => {
|
||||
const original = page();
|
||||
const history = new EditorHistory(original);
|
||||
assert.deepEqual(progress(history.document, 'en'), { total: 6, done: 0, stale: 0 }, 'Leere und dekorative Felder zählen nicht.');
|
||||
|
||||
history.change(doc => setText(doc, 'en', 'h1.text', 'Services'));
|
||||
history.change(doc => setText(doc, 'en', 'title', 'Services'));
|
||||
history.change(doc => setPageValue(doc, 'en', 'slug', ' Services '));
|
||||
const doc = history.document;
|
||||
assert.equal(doc.schemaVersion, 27);
|
||||
assert.equal(doc.blocks[0].children[0].props.text, 'Leistungen', 'Die Hauptsprache bleibt unberührt.');
|
||||
assert.deepEqual(doc.translations.en, { blocks: { h1: { text: 'Services' } }, source: { 'h1.text': '16a48bdd', title: '16a48bdd' }, title: 'Services', slug: 'services' });
|
||||
assert.deepEqual(progress(doc, 'en'), { total: 6, done: 2, stale: 0 });
|
||||
|
||||
// Das Original ändert sich: die Übersetzung gilt als veraltet, bis sie bestätigt wird.
|
||||
history.change(content => { content.blocks[0].children[0].props.text = 'Unsere Leistungen'; });
|
||||
assert.equal(entries(history.document, 'en').find(entry => entry.key === 'h1.text').state, 'stale');
|
||||
history.change(content => confirmText(content, 'en', 'h1.text'));
|
||||
assert.equal(entries(history.document, 'en').find(entry => entry.key === 'h1.text').state, 'done');
|
||||
|
||||
// Leeren entfernt den Text samt Vermerk; eine leere Sprache verschwindet ganz.
|
||||
history.change(content => { setText(content, 'en', 'h1.text', ''); setText(content, 'en', 'title', ''); setPageValue(content, 'en', 'slug', ''); });
|
||||
assert.equal(history.document.translations, undefined);
|
||||
|
||||
while (history.past.length) history.undo();
|
||||
assert.deepEqual(history.document, original);
|
||||
});
|
||||
Reference in new issue
Block a user