// The site's pages are written in English, in the HTML. An element whose text is translated carries // `data-i18n=""`; a translated attribute carries `data-i18n-=""` (aria-label, // alt, placeholder, title). From those marks this file // - extracts the English source messages (scripts/i18n-site.mjs writes them to locales/en.json), // - renders a page in another language (store-pages.ts, at build time), and // - removes the marks from what is published. // // A message is plain text in the framework's catalog format (ICU-lite, never HTML; docs/TRANSLATING.md). // Inline markup becomes placeholders named after the tag: `{a1}…{a1End}` wraps the words of the first // link, `{em1}…{em1End}` the first emphasis; an element that is not translated (code, a key, a number, // anything with translate="no") is one placeholder, `{code1}`. A translation may move them, and the // catalog check refuses one that drops or invents a placeholder. // // Pure string functions, no Node APIs: the build, the extract script and the tests share them. export type HtmlNode = | { kind: 'text'; start: number; end: number } | { kind: 'comment'; start: number; end: number } | { kind: 'el'; tag: string; attrs: string; start: number; openEnd: number; closeStart: number; end: number; children: HtmlNode[] }; type El = Extract; const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']); const RAW = new Set(['script', 'style']); const TOKEN = /|]*>|<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/gi; /** A light tree of `html` with source offsets. Enough for the site's own well-formed pages. */ export function parseHtml(html: string): HtmlNode[] { const root: { children: HtmlNode[] } = { children: [] }; const stack: { tag?: string; children: HtmlNode[] }[] = [root]; const lower = html.toLowerCase(); const re = new RegExp(TOKEN.source, 'gi'); let last = 0; const text = (to: number) => { if (to > last) stack.at(-1)!.children.push({ kind: 'text', start: last, end: to }); }; for (let match = re.exec(html); match; match = re.exec(html)) { text(match.index); last = re.lastIndex; if (match[0].startsWith(' 0 && stack[at]!.tag !== tag) at -= 1; if (at === 0) continue; while (stack.length - 1 > at) { const open = stack.pop() as El; open.closeStart = open.end = match.index; } const el = stack.pop() as El; el.closeStart = match.index; el.end = re.lastIndex; continue; } const tag = match[2]!.toLowerCase(); const el: El = { kind: 'el', tag, attrs: match[3] ?? '', start: match.index, openEnd: re.lastIndex, closeStart: re.lastIndex, end: re.lastIndex, children: [] }; stack.at(-1)!.children.push(el); if (VOID.has(tag) || /\/\s*$/.test(el.attrs)) continue; if (RAW.has(tag)) { const close = lower.indexOf(`', close) + 1; el.closeStart = close < 0 ? html.length : close; el.end = end; re.lastIndex = last = end; continue; } stack.push(el); } text(html.length); while (stack.length > 1) { const open = stack.pop() as El; open.closeStart = open.end = html.length; } return root.children; } export function attrOf(el: El, name: string): string | null { const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(el.attrs); return match ? match[1] ?? match[2] ?? '' : null; } const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', middot: '·', rarr: '→', larr: '←', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', hellip: '…', mdash: '—', ndash: '–', empty: '∅', times: '×', copy: '©', }; export function decodeEntities(text: string): string { return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => { if (name[0] === '#') return String.fromCodePoint(name[1]!.toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1))); const value = ENTITIES[name]; if (value === undefined) throw new Error(`i18n: unknown entity ${whole} (add it to ENTITIES in i18n-html.ts)`); return value; }); } const escapeText = (text: string) => text.replace(/[&<>\u00a0]/g, ch => ({ '&': '&', '<': '<', '>': '>', '\u00a0': ' ' })[ch]!); const escapeAttr = (text: string) => text.replace(/[&<>"\u00a0]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', '\u00a0': ' ' })[ch]!); /** * Values the build fills in, as message placeholders: `` in a translated * sentence is `{themes}`, `%FORGE_HOST%` is `{host}`. Numbers may be pluralized by a translation. */ export const VALUE_TOKENS: Record = { '': 'themes', '': 'plugins', '': 'items', '': 'date', '': 'apps', '': 'version', '%FORGE_HOST%': 'host', }; const TOKEN_SPLIT = /(%[A-Z_]+%)/; const hasLetters = (text: string) => /\p{L}/u.test(text); const textOf = (html: string, nodes: HtmlNode[]): string => nodes.map(node => (node.kind === 'text' ? html.slice(node.start, node.end) : node.kind === 'el' ? textOf(html, node.children) : '')).join(''); const OPAQUE_TAGS = new Set(['code', 'kbd', 'svg', 'img', 'br', 'video', 'pre', 'input', 'picture']); /** An element a translation carries along whole: code, keys, icons, numbers, and anything marked translate="no". */ export function isOpaque(html: string, el: El): boolean { return OPAQUE_TAGS.has(el.tag) || attrOf(el, 'translate') === 'no' || !hasLetters(decodeEntities(textOf(html, el.children))); } /** Decoration at either end of a translated element (an icon, a dot, a caret, a symbol): it stays where it is, outside the message. */ function isDecoration(html: string, el: El): boolean { return el.tag === 'svg' || el.tag === 'img' || attrOf(el, 'aria-hidden') === 'true' || !/[\p{L}\p{N}]/u.test(decodeEntities(textOf(html, el.children))); } const icuEscape = (text: string) => text.replace(/'(?=[{}#'])/g, "''").replace(/[{}]/g, ch => `'${ch}'`); export type Message = { key: string; /** The English source, in catalog syntax. */ source: string; /** Placeholder → the HTML it stands for (an opening tag, a closing tag, or a whole element). */ slots: Record; /** Placeholders the build gives a value (VALUE_TOKENS). */ values: string[]; /** Where the rendered message goes: offsets in the page, or the attribute it is the value of. */ start: number; end: number; attr?: string; /** For translators: what each placeholder is. */ notes: string[]; }; const collapse = (text: string) => text.replace(/[ \t\r\n]+/g, ' '); function valueText(text: string, values: Set): string { return text.split(TOKEN_SPLIT).map(part => { if (!TOKEN_SPLIT.test(part)) return icuEscape(part); const name = VALUE_TOKENS[part]; if (!name) throw new Error(`i18n: ${part} inside a translated text has no placeholder name (VALUE_TOKENS in i18n-html.ts)`); values.add(name); return `{${name}}`; }).join(''); } function contentMessage(html: string, el: El, key: string): Message | null { const kids = el.children; const skip = (node: HtmlNode) => (node.kind === 'text' ? !html.slice(node.start, node.end).trim() : node.kind === 'el' ? isDecoration(html, node) : !VALUE_TOKENS[html.slice(node.start, node.end)]); let first = 0; let last = kids.length - 1; while (first <= last && skip(kids[first]!)) first += 1; while (last >= first && skip(kids[last]!)) last -= 1; if (first > last) return null; const slots: Record = {}; const values = new Set(); const notes: string[] = []; const counters = new Map(); const name = (tag: string) => { const n = (counters.get(tag) ?? 0) + 1; counters.set(tag, n); return `${tag.replace(/[^a-z]/g, '')}${n}`; }; const serialize = (nodes: HtmlNode[]): string => nodes.map(node => { const raw = html.slice(node.start, node.end); if (node.kind === 'text') return valueText(decodeEntities(raw), values); if (node.kind === 'comment') { const value = VALUE_TOKENS[raw]; if (value) { values.add(value); return `{${value}}`; } if (/^