/** * Ü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); }