localization optimization to avoid regex
Sonarqube Scanner / Prepare environment (push) Successful in 15s
Sonarqube Scanner / Build and test (push) Successful in 43s
Sonarqube Scanner / SonarQube analysis (push) Failing after 2m10s
Sonarqube Scanner / Deploy to Server (push) Successful in 10s

This commit is contained in:
Florian Zumpe
2026-10-01 17:00:08 +02:00
parent 5c4d20bd1d
commit ad09fecfd5
2 changed files with 138 additions and 13 deletions
+105 -13
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const escapeAttribute = value => escapeText(value).replaceAll('"', '&quot;').replaceAll("'", '&#39;');
// Populate the checked-in German fallback while retaining all runtime i18n markers.
function tagEnd(source, start) {
if (source.startsWith('<!--', start)) {
const end = source.indexOf('-->', 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('"', '&quot;');
// 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('</')) {
result.push(escapeText(translate(key)));
index += 1;
}
return translated;
});
}
return result.join('');
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {