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:
kantarciandClaude Opus 5.5 committed 2026-10-09 16:56:52 +02:00
1 parent c7705b7083
commit e15b2517b1
51 files changed
+3045 -219

No files matched your search

+55
View File
@@ -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);
});