feat(i18n): find UI text that is not marked for translation

npm run i18n now also lists, and fails on, markup text and title /
aria-label / placeholder without data-i18n*, and string literals that reach
the page (textContent, innerHTML, setAttribute, toasts, label:/title:
properties and similar) without t(), tn() or msg(). Opt-outs say why where
the text lives: translate="no" and data-i18n-runtime in markup,
// i18n-ignore in code. src/tools/ is skipped: its text is for AI clients.

534 places are reported today; npm test does not run this part yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 18:41:34 +04:00
1 parent 6ff7afcc8b
commit ee72133574
3 files changed
+358 -19

No files matched your search

+51
View File
@@ -6,6 +6,8 @@ import {
extractMessages,
readCatalog,
shippedLocales,
unmarkedMarkup,
unmarkedScript,
} from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js';
@@ -55,3 +57,52 @@ test('checkCatalog reports missing, stale, placeholder and plural problems', ()
assert.deepEqual(result.stale, ['Removed']);
assert.equal(result.errors.length, 2);
});
test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text);
assert.deepEqual(texts('<span>Close</span>'), ['Close']);
assert.deepEqual(texts('<span data-i18n>Close</span>'), []);
assert.deepEqual(texts('<button title="Pin it">x</button>'), [
'title="Pin it"',
]);
assert.deepEqual(
texts('<button title="Pin it" data-i18n-title>x</button>'),
[],
);
assert.deepEqual(
texts('<select translate="no"><option>0.9 dB</option></select>'),
[],
);
assert.deepEqual(texts('<b data-i18n-runtime><i>OFF</i></b>'), []);
assert.deepEqual(
texts('<span class="material-symbols-outlined">public</span>'),
[],
);
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
});
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
assert.deepEqual(texts("el.textContent = t('Saved');"), []);
assert.deepEqual(texts("el.textContent = on ? 'On air' : 'Off air';"), [
'On air',
'Off air',
]);
assert.deepEqual(texts("el.setAttribute('aria-label', `Open ${name}`);"), [
'Open',
]);
assert.deepEqual(
texts("const row = { label: msg('Wind'), id: 'wind' };"),
[],
);
assert.deepEqual(texts("const row = { label: 'Wind' };"), ['Wind']);
assert.deepEqual(texts("el.textContent = labelFor('flights');"), []);
assert.deepEqual(texts("el.className = 'is-active';"), []);
assert.deepEqual(texts("el.title = mode === 'Auto mode' ? a : b;"), []);
assert.deepEqual(texts("// el.textContent = 'Old copy';"), []);
assert.deepEqual(
texts("// i18n-ignore: product name\nel.textContent = 'OpenSky';"),
[],
);
});