localization optimization to avoid regex
This commit is contained in:
+106
-14
@@ -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.
|
||||
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}"`);
|
||||
function tagEnd(source, start) {
|
||||
if (source.startsWith('<!--', start)) {
|
||||
const end = source.indexOf('-->', start + 4);
|
||||
return end < 0 ? -1 : end + 2;
|
||||
}
|
||||
return translated;
|
||||
});
|
||||
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 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('</')) {
|
||||
result.push(escapeText(translate(key)));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
|
||||
@@ -31,6 +31,39 @@ test('HTML generation escapes text and attributes, preserves i18n markers and up
|
||||
assert.equal(localizeHtml(result, translate), result);
|
||||
});
|
||||
|
||||
test('HTML scanning respects quoted tag delimiters, comments, boolean attributes and nested content', () => {
|
||||
const source = '<!-- <h1 data-i18n="ignored">do not translate</h1> -->'
|
||||
+ '<button disabled title=old data-i18n-title="label" data-note="a > b">?</button>'
|
||||
+ "<h1 data-i18n='heading'>old</h1>"
|
||||
+ '<p data-i18n="parent"><strong>Keep nested text</strong></p>';
|
||||
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('<!-- <h1 data-i18n="ignored">do not translate</h1> -->'));
|
||||
assert.ok(result.includes('disabled title="Sam's "label""'));
|
||||
assert.ok(result.includes('data-note="a > b"'));
|
||||
assert.ok(result.includes("<h1 data-i18n='heading'>New heading</h1>"));
|
||||
assert.ok(result.includes('<p data-i18n="parent"><strong>Keep nested text</strong></p>'));
|
||||
});
|
||||
|
||||
test('HTML scanning preserves incomplete tags, quoted attributes and comments', () => {
|
||||
for (const tail of ['<h1 data-i18n="title"', '<button title="unfinished >', '<!-- unfinished <h1>']) {
|
||||
const source = `Plain text${tail}`;
|
||||
assert.equal(localizeHtml(source, () => assert.fail('Incomplete markup must not be translated')), source);
|
||||
}
|
||||
});
|
||||
|
||||
test('HTML scanning handles long malformed input without regex backtracking', { timeout: 2000 }, () => {
|
||||
const source = '<'.repeat(200000) + ' unfinished';
|
||||
assert.equal(localizeHtml(source, () => assert.fail('Malformed markup must not be translated')), source);
|
||||
const attribute = 'x'.repeat(200000);
|
||||
const html = `<h1 data-note="${attribute}" data-i18n="heading">old</h1>`;
|
||||
assert.equal(localizeHtml(html, () => 'New heading'), `<h1 data-note="${attribute}" data-i18n="heading">New heading</h1>`);
|
||||
});
|
||||
|
||||
test('translations support placeholders, plurals and German fallback without treating text as HTML', async () => {
|
||||
const context = {};
|
||||
const i18n = await loadI18n(context);
|
||||
|
||||
Reference in New Issue
Block a user