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>
161 lines
8.3 KiB
JavaScript
161 lines
8.3 KiB
JavaScript
/**
|
|
* Übersetzen im Editor (ADR 111).
|
|
*
|
|
* Eine Seite wird einmal gebaut; je weitere Sprache trägt ihr Dokument unter `translations` nur
|
|
* die Texte, adressiert über die ID des Blocks. Dieses Modul kennt die übersetzbaren Felder,
|
|
* schreibt eine Übersetzung in das Dokument und sagt, was fehlt oder veraltet ist. Es ändert
|
|
* nie Aufbau, Gestaltung oder die Texte der Hauptsprache.
|
|
*/
|
|
import { locate, upgradeDocument, blockLabel } from './editor-model.js';
|
|
|
|
/** Dieselbe Liste wie Translations::FIELDS, mit Beschriftung, Länge und Art des Feldes. */
|
|
export const FIELDS = {
|
|
heading: [{ prop: 'text', label: 'Überschrift', max: 2000, rich: 'heading' }],
|
|
text: [{ prop: 'text', label: 'Text', max: 20000, rich: 'text', multiline: true }],
|
|
button: [{ prop: 'text', label: 'Beschriftung', max: 300 }],
|
|
image: [{ prop: 'alt', label: 'Alternativtext', max: 300, multiline: true }, { prop: 'caption', label: 'Bildunterschrift', max: 500, multiline: true }],
|
|
iconcard: [{ prop: 'title', label: 'Titel', max: 120 }, { prop: 'text', label: 'Text', max: 600, multiline: true }],
|
|
stat: [{ prop: 'value', label: 'Wert', max: 16 }, { prop: 'label', label: 'Beschriftung', max: 80 }],
|
|
quote: [{ prop: 'text', label: 'Zitat', max: 1000, multiline: true }, { prop: 'author', label: 'Name', max: 120 }, { prop: 'role', label: 'Rolle', max: 120 }],
|
|
cta: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'text', label: 'Text', max: 600, multiline: true }, { prop: 'buttonText', label: 'Button', max: 80 }],
|
|
contact: [{ prop: 'address', label: 'Anschrift', max: 200, multiline: true }],
|
|
accordionitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
|
tabitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
|
legal: [{ prop: 'label', label: 'Beschriftung für Vorlesesoftware', max: 60 }],
|
|
postnav: [{ prop: 'previousLabel', label: 'Beschriftung zurück', max: 60 }, { prop: 'nextLabel', label: 'Beschriftung weiter', max: 60 }],
|
|
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
|
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
|
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
|
|
};
|
|
|
|
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
|
|
export const PAGE_FIELDS = [
|
|
{ key: 'title', label: 'Seitentitel', max: 160 },
|
|
{ key: 'description', label: 'Beschreibung für Suchmaschinen', max: 320, multiline: true },
|
|
];
|
|
|
|
/**
|
|
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah: FNV-1a über UTF-8, 32 Bit,
|
|
* als acht Hexziffern - dasselbe wie hash('fnv1a32') in PHP (Translations::fingerprint).
|
|
*/
|
|
export function fingerprint(text) {
|
|
let hash = 0x811c9dc5;
|
|
for (const byte of new TextEncoder().encode(text)) {
|
|
hash ^= byte;
|
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
}
|
|
return hash.toString(16).padStart(8, '0');
|
|
}
|
|
|
|
const empty = value => typeof value !== 'string' || value.trim() === '';
|
|
|
|
/** Das sichtbare Wort eines formatierten Texts, für Überschriften in der Liste. */
|
|
export function plainText(markup) {
|
|
const node = document.createElement('template');
|
|
node.innerHTML = markup ?? '';
|
|
return node.content.textContent.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
/**
|
|
* Alle übersetzbaren Texte des Dokuments in Seitenreihenfolge, je mit Original, Übersetzung und
|
|
* Zustand: "missing" (fehlt), "stale" (Original seitdem geändert) oder "done".
|
|
*
|
|
* @return {Array<{key: string, blockId: string|null, prop: string, label: string, block: object|null, original: string, value: string, state: string, field: object}>}
|
|
*/
|
|
export function entries(document, locale) {
|
|
const translation = document.translations?.[locale] ?? {};
|
|
const list = [];
|
|
const push = (key, blockId, prop, field, original, value, block = null) => {
|
|
if (empty(original)) return;
|
|
const hash = translation.source?.[key];
|
|
const state = empty(value) ? 'missing' : hash && hash !== fingerprint(original) ? 'stale' : 'done';
|
|
list.push({ key, blockId, prop, label: field.label, block, original, value: value ?? '', state, field });
|
|
};
|
|
for (const field of PAGE_FIELDS) push(field.key, null, field.key, field, document[field.key], translation[field.key]);
|
|
const walk = blocks => {
|
|
for (const block of blocks ?? []) {
|
|
for (const field of FIELDS[block.type] ?? []) {
|
|
// Ein dekoratives Bild oder Symbol hat keinen Text, also auch keinen zu übersetzen.
|
|
if (['alt', 'label'].includes(field.prop) && block.props?.decorative === true) continue;
|
|
push(`${block.id}.${field.prop}`, block.id, field.prop, field, block.props?.[field.prop], translation.blocks?.[block.id]?.[field.prop], block);
|
|
}
|
|
walk(block.children);
|
|
}
|
|
};
|
|
walk(document.blocks);
|
|
return list;
|
|
}
|
|
|
|
/** Zählt, was übersetzt ist: { total, done, stale }. */
|
|
export function progress(document, locale) {
|
|
const list = entries(document, locale);
|
|
return { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
|
|
}
|
|
|
|
/** Die Übersetzung einer Sprache, angelegt, falls es sie noch nicht gibt. */
|
|
function ensure(document, locale) {
|
|
upgradeDocument(document, 27);
|
|
document.translations ??= {};
|
|
return (document.translations[locale] ??= {});
|
|
}
|
|
|
|
/** Räumt eine Sprache auf, die nichts mehr enthält, damit das Dokument nicht wächst. */
|
|
function tidy(document, locale) {
|
|
const translation = document.translations?.[locale];
|
|
if (!translation) return;
|
|
for (const [id, fields] of Object.entries(translation.blocks ?? {})) if (!Object.keys(fields).length) delete translation.blocks[id];
|
|
if (translation.blocks && !Object.keys(translation.blocks).length) delete translation.blocks;
|
|
if (translation.source && !Object.keys(translation.source).length) delete translation.source;
|
|
if (!Object.keys(translation).length) delete document.translations[locale];
|
|
if (document.translations && !Object.keys(document.translations).length) delete document.translations;
|
|
}
|
|
|
|
/**
|
|
* Schreibt einen übersetzten Text. Mit dem Text wird vermerkt, wie das Original gerade aussieht -
|
|
* ändert es sich später, gilt die Übersetzung als veraltet. Leer heißt: nicht übersetzt.
|
|
* Läuft innerhalb einer Transaktion der History.
|
|
*/
|
|
export function setText(document, locale, key, value) {
|
|
const translation = ensure(document, locale);
|
|
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
|
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
|
if (blockId) {
|
|
translation.blocks ??= {};
|
|
const fields = (translation.blocks[blockId] ??= {});
|
|
if (empty(value)) delete fields[prop]; else fields[prop] = value;
|
|
} else if (empty(value)) delete translation[prop]; else translation[prop] = value;
|
|
translation.source ??= {};
|
|
if (empty(value) || typeof original !== 'string') delete translation.source[key];
|
|
else translation.source[key] = fingerprint(original);
|
|
tidy(document, locale);
|
|
}
|
|
|
|
/** Bestätigt eine veraltete Übersetzung, ohne sie zu ändern: das Original ist so gemeint. */
|
|
export function confirmText(document, locale, key) {
|
|
const translation = document.translations?.[locale];
|
|
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
|
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
|
if (!translation || typeof original !== 'string') return;
|
|
(translation.source ??= {})[key] = fingerprint(original);
|
|
}
|
|
|
|
/** Adresse und Suchtitel einer Sprache. Leer heißt: wie in der Hauptsprache beziehungsweise wie der Titel. */
|
|
export function setPageValue(document, locale, key, value) {
|
|
const translation = ensure(document, locale);
|
|
if (empty(value)) delete translation[key]; else translation[key] = key === 'slug' ? value.trim().toLowerCase() : value;
|
|
tidy(document, locale);
|
|
}
|
|
|
|
/** Aus einem Titel eine Adresse: Kleinbuchstaben, Ziffern, einzelne Bindestriche. */
|
|
export function slugify(text) {
|
|
return plainText(text).toLowerCase()
|
|
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
|
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/g, '');
|
|
}
|
|
|
|
/** Kurzer Name eines Blocks für die Liste: Art und der Anfang seines Originaltexts. */
|
|
export function describeBlock(block) {
|
|
return blockLabel(block);
|
|
}
|