diff --git a/js/i18n.js b/js/i18n.js index 980e077..d08726d 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -45,22 +45,35 @@ async function _loadLang(lang) { // ── Core API ────────────────────────────────────────────────────────────────── -/** - * Look up a translation key in the current language, falling back to English. - * Replace {placeholder} tokens with values from `params`. - */ -export function t(key, params = {}) { +function _interpolate(key, params, escape) { const strings = _cache[_currentLang] ?? _cache.en ?? {}; const fallback = _cache.en ?? {}; let str = strings[key] ?? fallback[key] ?? key; for (const [k, v] of Object.entries(params)) { - str = str.replaceAll(`{${k}}`, v); + str = str.replaceAll(`{${k}}`, escape ? _escapeHtml(v) : v); } return str; } +const _escapeHtml = (v) => String(v) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +/** Translate key + interpolate {placeholder}s. Text sinks only; innerHTML uses tHtml(). */ +export function t(key, params = {}) { + return _interpolate(key, params, false); +} + +/** t() with params HTML-escaped (templates stay raw). For innerHTML sinks. */ +export function tHtml(key, params = {}) { + return _interpolate(key, params, true); +} + export function getLang() { return _currentLang; } @@ -101,9 +114,9 @@ export function applyTranslations() { el.textContent = t(el.dataset.i18n); }); - // innerHTML (safe: all values are hardcoded in translation files, not user input) + // innerHTML — templates trusted, params escaped document.querySelectorAll('[data-i18n-html]').forEach(el => { - el.innerHTML = t(el.dataset.i18nHtml); + el.innerHTML = tHtml(el.dataset.i18nHtml); }); // title attribute diff --git a/js/main.js b/js/main.js index 5f641cd..270ebd2 100644 --- a/js/main.js +++ b/js/main.js @@ -17,7 +17,7 @@ import { buildAdjacency, bucketFill, buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js'; import { runFastDiagnostics, runExpensiveDiagnostics, getEdgePositions, getShellAssignments } from './meshValidation.js'; -import { t, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js'; +import { t, tHtml, initLang, setLang, getLang, applyTranslations, TRANSLATIONS } from './i18n.js'; import { QuantizedPointMap } from './meshIndex.js'; import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; @@ -3138,7 +3138,7 @@ function renderFastDiag(diag) { meshDiagFast.appendChild(makeDiagLine(t('diag.multipleShells', { n: diag.shellCount }), 'shells')); const tip = document.createElement('div'); tip.style.cssText = 'margin-top:4px;opacity:0.8;font-size:10px'; - tip.innerHTML = t('diag.recommendFix'); + tip.innerHTML = tHtml('diag.recommendFix'); meshDiagFast.appendChild(tip); } applyDiagSeverity(); @@ -3156,7 +3156,7 @@ function renderAdvancedDiag(results) { meshDiagAdvanced.appendChild(makeDiagLine(t('diag.overlappingTris', { n: results.overlappingPairs }), 'overlaps')); const tip = document.createElement('div'); tip.style.cssText = 'margin-top:4px;opacity:0.8;font-size:10px'; - tip.innerHTML = t('diag.recommendFix'); + tip.innerHTML = tHtml('diag.recommendFix'); meshDiagAdvanced.appendChild(tip); } applyDiagSeverity(); @@ -3225,7 +3225,7 @@ function applySmartResolution() { const clampedNote = d.budgetClamped ? ` [${t('ui.smartResBudgetCapped')}]` : ''; - smartResInfo.innerHTML = t('ui.smartResInfo', { + smartResInfo.innerHTML = tHtml('ui.smartResInfo', { edge: result.edge.toFixed(2), ppe: d.pixelsPerEdge.toFixed(1), pix: d.pixMm.toFixed(3),