Files
KantSites/public/assets/translate.js
T
kantarciandClaude Opus 5.5 e15b2517b1 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>
2026-10-09 16:56:52 +02:00

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);
}