Files
iptvnator/tools/i18n/check-usage.mjs
T
4grayandClaude Opus 5.5 6e18983400 fix(i18n): translate hard-coded labels, snackbars and missing keys (#1867)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:02:21 +02:00

292 lines
9.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Fails when the renderer references a translation key that en.json lacks.
* ngx-translate renders a missing key as the key itself, so the gap only
* shows up on screen; this check catches it before review.
*
* Production `.ts` and `.html` files under the source roots are scanned,
* with comments removed. A key counts as used when it appears as:
*
* - a quoted literal before `| translate`, in templates and inline templates,
* or a ternary/fallback branch of a parenthesised operand before it;
* - the first argument of a translate call: `instant`, `get` or `stream` on a
* receiver whose name contains "translat", a function whose name contains
* "translat" (`translateWithFallback`, `translateText`), `marker` or `t`;
* - a dotted upper-case literal whose first segment is an en.json namespace,
* such as `'PORTALS.ITEMS'` in a constant map. Such a literal may also name
* a group of keys that code completes, as in `${prefix}.TITLE`;
* - the static prefix of a template literal, `EPG.DIALOG.${name}`, which
* must name a group of keys.
*
* Keys assembled entirely at runtime cannot be checked here.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { collectLeaves } from './check-drift.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const REPO_ROOT = resolve(__dirname, '../..');
export const EN_PATH = resolve(REPO_ROOT, 'apps/web/src/assets/i18n/en.json');
/** Projects that load apps/web/src/assets/i18n at runtime. */
export const SOURCE_ROOTS = [
'apps/web/src',
'apps/remote-control-web/src',
'libs',
];
const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'out-tsc', 'testing']);
const SKIPPED_FILE =
/\.(spec|test|e2e|stories)\.ts$|\.d\.ts$|\.spec-[\w-]+\.ts$|harness\.ts$|[\\/]test-stubs[\\/]/;
const KEY = '[A-Z][A-Z0-9_]*(?:\\.[A-Z0-9_]+)*';
const PIPE_USAGE = /(['"])([A-Za-z0-9_.-]+)\1\s*\|\s*translate\b/g;
const PIPE_AFTER_GROUP = /\)\s*\|\s*translate\b/g;
/**
* A key that a grouped pipe operand can yield: a ternary or fallback branch.
* The literal must also end the operand (end of group, `:`, `||` or `??`),
* so a value compared in a condition (`'ERROR' === x ? …`) is not a key.
*/
const GROUP_OPERAND = new RegExp(
`(^|[?:|])\\s*(['"])(${KEY})\\2(?=\\s*(?:$|:|\\|\\||\\?\\?))`,
'g'
);
const CALL_USAGE = new RegExp(
`([A-Za-z_$][\\w$]*(?:\\s*\\??\\.\\s*[A-Za-z_$][\\w$]*)*)\\s*\\(\\s*(['"\`])(${KEY})\\2`,
'g'
);
const QUOTED_DOTTED = /(['"`])([A-Z][A-Z0-9_]*(?:\.[A-Z0-9_]+)+)\1/g;
const TEMPLATE_PREFIX = /`([A-Z][A-Z0-9_]*(?:\.[A-Z0-9_]+)*)\.\$\{/g;
const CODE_TOKENS =
/("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
const HTML_COMMENTS = /<!--[\s\S]*?-->/g;
function isTranslateCall(callee) {
const parts = callee.replace(/\s|\?/g, '').split('.');
const name = parts.at(-1);
if (/translat/i.test(name) || name === 'marker' || name === 't') {
return true;
}
return (
['instant', 'get', 'stream'].includes(name) &&
parts.length > 1 &&
/translat/i.test(parts.at(-2))
);
}
/**
* Replaces comments with blanks, keeping offsets and line numbers. In
* TypeScript the HTML comments of inline templates go too.
*/
export function stripComments(source, kind) {
const blank = (text) => text.replace(/[^\n]/g, ' ');
if (kind === 'html') {
return source.replace(HTML_COMMENTS, blank);
}
return source
.replace(CODE_TOKENS, (match, literal) =>
literal ? match : blank(match)
)
.replace(HTML_COMMENTS, blank);
}
/** Index of the `(` that opens the group closing at `closeIndex`, or -1. */
function findGroupStart(code, closeIndex) {
let depth = 0;
for (let i = closeIndex; i >= 0; i -= 1) {
if (code[i] === ')') {
depth += 1;
} else if (code[i] === '(') {
depth -= 1;
if (depth === 0) {
return i;
}
}
}
return -1;
}
/**
* Keys a parenthesised pipe operand can yield, as in
* `(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate`. Only whole
* branches count (see GROUP_OPERAND), so a literal compared in the condition
* is not read as a key.
*/
function findGroupedPipeKeys(code) {
const references = [];
for (const match of code.matchAll(PIPE_AFTER_GROUP)) {
const open = findGroupStart(code, match.index);
if (open < 0) {
continue;
}
const inner = code.slice(open + 1, match.index);
for (const operand of inner.matchAll(GROUP_OPERAND)) {
references.push({
key: operand[3],
index:
open + 1 + operand.index + operand[0].indexOf(operand[2]),
kind: 'leaf',
});
}
}
return references;
}
/**
* Returns `{ key, index, kind }` for every reference in one file. `kind` is
* `leaf` when the reference must be a translatable string and `group` when a
* group of keys also satisfies it.
*/
export function findKeyReferences(source, kind, namespaces) {
const code = stripComments(source, kind);
const references = [];
for (const match of code.matchAll(PIPE_USAGE)) {
references.push({ key: match[2], index: match.index, kind: 'leaf' });
}
references.push(...findGroupedPipeKeys(code));
for (const match of code.matchAll(CALL_USAGE)) {
if (isTranslateCall(match[1])) {
references.push({
key: match[3],
index: match.index + match[0].lastIndexOf(match[2] + match[3]),
kind: 'leaf',
});
}
}
for (const match of code.matchAll(QUOTED_DOTTED)) {
if (namespaces.has(match[2].split('.')[0])) {
references.push({
key: match[2],
index: match.index,
kind: 'group',
});
}
}
for (const match of code.matchAll(TEMPLATE_PREFIX)) {
if (namespaces.has(match[1].split('.')[0])) {
references.push({
key: match[1],
index: match.index,
kind: 'prefix',
});
}
}
return references;
}
function listSourceFiles(root) {
let entries;
try {
entries = readdirSync(root);
} catch (error) {
if (error?.code === 'ENOENT') {
return [];
}
throw error;
}
return entries.flatMap((entry) => {
const path = resolve(root, entry);
if (statSync(path).isDirectory()) {
return SKIPPED_DIRS.has(entry) ? [] : listSourceFiles(path);
}
return /\.(ts|html)$/.test(entry) && !SKIPPED_FILE.test(path)
? [path]
: [];
});
}
function lineOf(source, index) {
let line = 1;
for (let i = 0; i < index; i += 1) {
if (source.charCodeAt(i) === 10) {
line += 1;
}
}
return line;
}
/** Runs the check and returns the process exit code. */
export function run({
repoRoot = REPO_ROOT,
enPath = EN_PATH,
sourceRoots = SOURCE_ROOTS,
log = console.log,
} = {}) {
let leaves;
try {
leaves = collectLeaves(JSON.parse(readFileSync(enPath, 'utf8')));
} catch (error) {
log(
`FAIL ${enPath}: ${error instanceof Error ? error.message : error}`
);
return 1;
}
const groups = new Set();
for (const key of leaves.keys()) {
const segments = key.split('.');
for (let end = 1; end < segments.length; end += 1) {
groups.add(segments.slice(0, end).join('.'));
}
}
const namespaces = new Set([...groups].filter((key) => !key.includes('.')));
const missing = new Map();
let files = 0;
for (const root of sourceRoots) {
for (const path of listSourceFiles(resolve(repoRoot, root))) {
files += 1;
const source = readFileSync(path, 'utf8');
const kind = path.endsWith('.html') ? 'html' : 'ts';
for (const reference of findKeyReferences(
source,
kind,
namespaces
)) {
const known =
reference.kind === 'leaf'
? leaves.has(reference.key)
: reference.kind === 'prefix'
? groups.has(reference.key)
: leaves.has(reference.key) ||
groups.has(reference.key);
if (known) {
continue;
}
const location = `${relative(repoRoot, path)}:${lineOf(
source,
reference.index
)}`;
if (!missing.has(reference.key)) {
missing.set(reference.key, new Set());
}
missing.get(reference.key).add(location);
}
}
}
if (missing.size === 0) {
log(`ok usage: ${files} files, 0 keys missing from en.json`);
return 0;
}
log(
`FAIL usage: ${missing.size} key(s) are used but missing from en.json:`
);
for (const [key, locations] of [...missing].sort(([a], [b]) =>
a < b ? -1 : 1
)) {
log(` ${key} ${[...locations].join(', ')}`);
}
log(
'Add each key to en.json and translate it in every locale (see the i18n-fill skill).'
);
return 1;
}
const isMain =
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
process.exitCode = run();
}