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("'", '''); 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) }, { 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 = []; let skipText = false; for (const [index, part] of parts.entries()) { if (skipText) { skipText = false; continue; } 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(' globalThis.Wegwichtel.I18n.t(key)); await fs.writeFile(file, translated); }