diff --git a/scripts/localize-html.js b/scripts/localize-html.js index 261a511..625a45a 100644 --- a/scripts/localize-html.js +++ b/scripts/localize-html.js @@ -1,23 +1,115 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; +const whitespace = new Set([' ', '\t', '\r', '\n', '\f']); +const translatedAttributes = ['title', 'aria-label', 'placeholder', 'alt']; const escapeText = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +const escapeAttribute = value => escapeText(value).replaceAll('"', '"').replaceAll("'", '''); -// Populate the checked-in German fallback while retaining all runtime i18n markers. +function tagEnd(source, start) { + if (source.startsWith('', start + 4); + return end < 0 ? -1 : end + 2; + } + let quote = ''; + for (let index = start + 1; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (character === quote) quote = ''; + } else if (character === '"' || character === "'") quote = character; + else if (character === '>') return index; + } + return -1; +} + +function tokens(source) { + const result = []; + let cursor = 0; + while (cursor < source.length) { + const start = source.indexOf('<', cursor); + if (start < 0) break; + const end = tagEnd(source, start); + if (end < 0) break; + result.push({ tag: false, value: source.slice(cursor, start) }); + result.push({ tag: true, value: source.slice(start, end + 1) }); + cursor = end + 1; + } + result.push({ tag: false, value: source.slice(cursor) }); + return result; +} + +function skipWhitespace(tag, start) { + let index = start; + while (whitespace.has(tag[index])) index += 1; + return index; +} + +function nameEnd(tag, start) { + let index = start; + while (index < tag.length && !whitespace.has(tag[index]) && !['=', '/', '>'].includes(tag[index])) index += 1; + return index; +} + +function attributeValue(tag, start) { + const quote = tag[start]; + if (quote === '"' || quote === "'") { + const end = tag.indexOf(quote, start + 1); + return { value: tag.slice(start + 1, end), start, end: end + 1 }; + } + let end = start; + while (end < tag.length && !whitespace.has(tag[end]) && tag[end] !== '>') end += 1; + return { value: tag.slice(start, end), start, end }; +} + +function attributes(tag) { + const result = new Map(); + if (['/', '!', '?'].includes(tag[1])) return result; + let index = nameEnd(tag, 1); + while (index < tag.length - 1) { + index = skipWhitespace(tag, index); + const end = nameEnd(tag, index); + if (end === index) break; + const name = tag.slice(index, end); + index = skipWhitespace(tag, end); + if (tag[index] !== '=') continue; + const attribute = attributeValue(tag, skipWhitespace(tag, index + 1)); + result.set(name, attribute); + index = attribute.end; + } + return result; +} + +function localizeAttributes(tag, fields, translate) { + const replacements = []; + for (const attribute of translatedAttributes) { + const key = fields.get(`data-i18n-${attribute}`)?.value; + const target = fields.get(attribute); + if (key && target) replacements.push({ ...target, value: `"${escapeAttribute(translate(key))}"` }); + } + replacements.sort((left, right) => right.start - left.start); + let result = tag; + for (const replacement of replacements) result = result.slice(0, replacement.start) + replacement.value + result.slice(replacement.end); + return result; +} + +// Scan the authored markup once. Keep comments, nested content and malformed tails intact. export function localizeHtml(source, translate) { - const result = source.replaceAll(/(<[^>]+\bdata-i18n="([^"]+)"[^>]*>)([^<]*)(<\/)/g, - (_match, opening, key, _text, closing) => opening + escapeText(translate(key)) + closing); - return result.replaceAll(/<[^>]+>/g, opening => { - let translated = opening; - for (const attribute of ['title', 'aria-label', 'placeholder', 'alt']) { - const key = new RegExp(`data-i18n-${attribute}="([^"]+)"`).exec(opening)?.[1]; - if (!key) continue; - const value = escapeText(translate(key)).replaceAll('"', '"'); - // Match an actual attribute, never the suffix of its data-i18n marker. - translated = translated.replaceAll(new RegExp(`(\\s)${attribute}="[^"]*"`, 'g'), (_match, space) => `${space}${attribute}="${value}"`); + const parts = tokens(source); + const result = []; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (!part.tag) { result.push(part.value); continue; } + const fields = attributes(part.value); + result.push(localizeAttributes(part.value, fields, translate)); + const key = fields.get('data-i18n')?.value; + const text = parts[index + 1]; + const closing = parts[index + 2]; + if (key && !text?.tag && closing?.value.startsWith(' { + const source = '' + + '' + + "

old

" + + '

Keep nested text

'; + const calls = []; + const result = localizeHtml(source, key => { + calls.push(key); + return { heading: 'New heading', label: 'Sam\'s "label"' }[key]; + }); + assert.deepEqual(calls, ['label', 'heading']); + assert.ok(result.includes('')); + assert.ok(result.includes('disabled title="Sam's "label""')); + assert.ok(result.includes('data-note="a > b"')); + assert.ok(result.includes("

New heading

")); + assert.ok(result.includes('

Keep nested text

')); +}); + +test('HTML scanning preserves incomplete tags, quoted attributes and comments', () => { + for (const tail of ['

old

`; + assert.equal(localizeHtml(html, () => 'New heading'), `

New heading

`); +}); + test('translations support placeholders, plurals and German fallback without treating text as HTML', async () => { const context = {}; const i18n = await loadI18n(context);