mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
style(client): format the i18n key check and its test
The new lint:format ratchet admits no unformatted file outside its baseline, and these two arrived after it was taken.
This commit is contained in:
+180
-177
@@ -49,18 +49,18 @@
|
||||
* variable (`t(item.labelKey)` with the table elsewhere, server-sent error
|
||||
* keys) look unused here.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Plain paths rather than `new URL(..., import.meta.url)`: under the test runner's DOM environment URL is
|
||||
// jsdom's, which fileURLToPath refuses.
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const SRC = join(HERE, '..', 'src')
|
||||
const SHARED_SCRIPTS = join(HERE, '..', '..', 'shared', 'scripts')
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = join(HERE, '..', 'src');
|
||||
const SHARED_SCRIPTS = join(HERE, '..', '..', 'shared', 'scripts');
|
||||
|
||||
/** How many en keys a template may reach and still pass without an entry below. */
|
||||
export const MAX_IMPLICIT_MATCHES = 30
|
||||
export const MAX_IMPLICIT_MATCHES = 30;
|
||||
|
||||
/**
|
||||
* Template keys the check cannot bound by itself: wider than
|
||||
@@ -106,7 +106,7 @@ export const DYNAMIC_ALLOWED = [
|
||||
file,
|
||||
template: 'reservations.${s}',
|
||||
because: "s iterates the literal ['pending', 'confirmed'] as const; reservations.pending/confirmed exist",
|
||||
}),
|
||||
})
|
||||
),
|
||||
...['components/Admin/AdminPluginsPanel.tsx', 'mobile/screens/admin/MAdminPluginsPanel.tsx'].map((file) => ({
|
||||
file,
|
||||
@@ -123,33 +123,33 @@ export const DYNAMIC_ALLOWED = [
|
||||
because:
|
||||
'field label and hint names come from the provider field rows PhotoProviderSeeder writes, each a ' +
|
||||
'memories.* key in en',
|
||||
})),
|
||||
}))
|
||||
),
|
||||
{
|
||||
file: 'components/Tours/planner/TourPlannerPanels.tsx',
|
||||
template: 'tours.planner.${role}',
|
||||
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/
|
||||
const PREFIX_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)*\.$/
|
||||
const CALL_RE = /(?<![\w$.])(?:t|tHtml|tr)\(|(?<=[\w$]\.)(?:t|tHtml)\(/g
|
||||
const HTML_PROP_RE = /<TransHtml\b[^>]*?\bhtml=(["'])([^"'\n]+)\1/g
|
||||
const API_ERROR_RE = /\btranslateApiError\(\s*t\s*,[^,()]*(?:\([^()]*\))?[^,()]*,\s*(['"])([^'"\n]+)\1/g
|
||||
const KEY_BUILDER_RE = /\bconst\s+[a-zA-Z]*Key\s*=\s*\([^)]*\)\s*(?::\s*[^=]+?)?=>\s*`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g
|
||||
const KEY_PROP_RE = /\b[a-zA-Z]*Key\s*(?::|=\{?)\s*(['"])([a-z][a-zA-Z0-9_]*\.[^'"\s]+)\1/g
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
const PREFIX_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)*\.$/;
|
||||
const CALL_RE = /(?<![\w$.])(?:t|tHtml|tr)\(|(?<=[\w$]\.)(?:t|tHtml)\(/g;
|
||||
const HTML_PROP_RE = /<TransHtml\b[^>]*?\bhtml=(["'])([^"'\n]+)\1/g;
|
||||
const API_ERROR_RE = /\btranslateApiError\(\s*t\s*,[^,()]*(?:\([^()]*\))?[^,()]*,\s*(['"])([^'"\n]+)\1/g;
|
||||
const KEY_BUILDER_RE = /\bconst\s+[a-zA-Z]*Key\s*=\s*\([^)]*\)\s*(?::\s*[^=]+?)?=>\s*`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g;
|
||||
const KEY_PROP_RE = /\b[a-zA-Z]*Key\s*(?::|=\{?)\s*(['"])([a-z][a-zA-Z0-9_]*\.[^'"\s]+)\1/g;
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) walk(path, files)
|
||||
else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name) && !name.endsWith('.d.ts')) files.push(path)
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, files);
|
||||
else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name) && !name.endsWith('.d.ts')) files.push(path);
|
||||
}
|
||||
return files
|
||||
return files;
|
||||
}
|
||||
|
||||
const lineAt = (text, index) => text.slice(0, index).split('\n').length
|
||||
const lineAt = (text, index) => text.slice(0, index).split('\n').length;
|
||||
|
||||
/**
|
||||
* The source of the first argument of the call whose `(` sits at `open`:
|
||||
@@ -157,41 +157,41 @@ const lineAt = (text, index) => text.slice(0, index).split('\n').length
|
||||
* strings, template literals and nested brackets skipped as units.
|
||||
*/
|
||||
export function firstArgument(text, open) {
|
||||
let depth = 0
|
||||
let depth = 0;
|
||||
for (let i = open + 1; i < text.length; i++) {
|
||||
const c = text[i]
|
||||
const c = text[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
for (i++; i < text.length && text[i] !== c; i++) {
|
||||
if (text[i] === '\\') i++
|
||||
if (text[i] === '\\') i++;
|
||||
else if (c === '`' && text[i] === '$' && text[i + 1] === '{') {
|
||||
// Skip the interpolation, counting its braces.
|
||||
let braces = 0
|
||||
let braces = 0;
|
||||
for (; i < text.length; i++) {
|
||||
if (text[i] === '{') braces++
|
||||
else if (text[i] === '}' && --braces === 0) break
|
||||
if (text[i] === '{') braces++;
|
||||
else if (text[i] === '}' && --braces === 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
if (c === '(' || c === '[' || c === '{') depth++
|
||||
if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if (c === ')' || c === ']' || c === '}') {
|
||||
if (depth === 0) return text.slice(open + 1, i)
|
||||
depth--
|
||||
} else if (c === ',' && depth === 0) return text.slice(open + 1, i)
|
||||
if (depth === 0) return text.slice(open + 1, i);
|
||||
depth--;
|
||||
} else if (c === ',' && depth === 0) return text.slice(open + 1, i);
|
||||
}
|
||||
return text.slice(open + 1)
|
||||
return text.slice(open + 1);
|
||||
}
|
||||
|
||||
/** The quoted literals and template literals of an argument's source. */
|
||||
export function literalsIn(arg) {
|
||||
const quoted = []
|
||||
const templates = []
|
||||
const quoted = [];
|
||||
const templates = [];
|
||||
for (const m of arg.matchAll(/(['"])((?:\\.|(?!\1)[^\\\n])*)\1|`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g)) {
|
||||
if (m[3] !== undefined) templates.push(m[3])
|
||||
else quoted.push(m[2])
|
||||
if (m[3] !== undefined) templates.push(m[3]);
|
||||
else quoted.push(m[2]);
|
||||
}
|
||||
return { quoted, templates }
|
||||
return { quoted, templates };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,10 +199,10 @@ export function literalsIn(arg) {
|
||||
* for each interpolation. Null when it has no fixed dotted prefix.
|
||||
*/
|
||||
export function templatePattern(template) {
|
||||
const parts = template.split(/\$\{[^}]*\}/)
|
||||
if (!/^[a-z][a-zA-Z0-9_]*\./.test(parts[0] ?? '')) return null
|
||||
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return new RegExp(`^${parts.map(escape).join('[^.]+')}$`)
|
||||
const parts = template.split(/\$\{[^}]*\}/);
|
||||
if (!/^[a-z][a-zA-Z0-9_]*\./.test(parts[0] ?? '')) return null;
|
||||
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${parts.map(escape).join('[^.]+')}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,36 +211,36 @@ export function templatePattern(template) {
|
||||
* `?.` is optional chaining.
|
||||
*/
|
||||
function topLevelOperators(expr) {
|
||||
const ops = []
|
||||
let depth = 0
|
||||
const ops = [];
|
||||
let depth = 0;
|
||||
for (let i = 0; i < expr.length; i++) {
|
||||
const c = expr[i]
|
||||
const c = expr[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
for (i++; i < expr.length && expr[i] !== c; i++) if (expr[i] === '\\') i++
|
||||
} else if (c === '(' || c === '[' || c === '{') depth++
|
||||
else if (c === ')' || c === ']' || c === '}') depth--
|
||||
else if (depth > 0) continue
|
||||
else if ((c === '?' && expr[i + 1] === '?') || (c === '|' && expr[i + 1] === '|')) ops.push({ op: c + c, at: i++ })
|
||||
else if (c === '?' && expr[i + 1] !== '.') ops.push({ op: '?', at: i })
|
||||
else if (c === ':') ops.push({ op: ':', at: i })
|
||||
for (i++; i < expr.length && expr[i] !== c; i++) if (expr[i] === '\\') i++;
|
||||
} else if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if (c === ')' || c === ']' || c === '}') depth--;
|
||||
else if (depth > 0) continue;
|
||||
else if ((c === '?' && expr[i + 1] === '?') || (c === '|' && expr[i + 1] === '|')) ops.push({ op: c + c, at: i++ });
|
||||
else if (c === '?' && expr[i + 1] !== '.') ops.push({ op: '?', at: i });
|
||||
else if (c === ':') ops.push({ op: ':', at: i });
|
||||
}
|
||||
return ops
|
||||
return ops;
|
||||
}
|
||||
|
||||
const QUOTED_RE = /^(['"])((?:\\.|(?!\1)[^\\\n])*)\1$/
|
||||
const QUOTED_RE = /^(['"])((?:\\.|(?!\1)[^\\\n])*)\1$/;
|
||||
|
||||
/** Whether `e` is one parenthesized expression, its first `(` closing at its last character. */
|
||||
function wrapsWhole(e) {
|
||||
if (!e.startsWith('(') || !e.endsWith(')')) return false
|
||||
let depth = 0
|
||||
if (!e.startsWith('(') || !e.endsWith(')')) return false;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < e.length; i++) {
|
||||
const c = e[i]
|
||||
const c = e[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
for (i++; i < e.length && e[i] !== c; i++) if (e[i] === '\\') i++
|
||||
} else if (c === '(') depth++
|
||||
else if (c === ')' && --depth === 0) return i === e.length - 1
|
||||
for (i++; i < e.length && e[i] !== c; i++) if (e[i] === '\\') i++;
|
||||
} else if (c === '(') depth++;
|
||||
else if (c === ')' && --depth === 0) return i === e.length - 1;
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,42 +251,42 @@ function wrapsWhole(e) {
|
||||
* never a result.
|
||||
*/
|
||||
export function interpolationResults(expr) {
|
||||
let e = expr.trim()
|
||||
while (wrapsWhole(e)) e = e.slice(1, -1).trim()
|
||||
const ops = topLevelOperators(e)
|
||||
const q = ops.findIndex((o) => o.op === '?')
|
||||
let e = expr.trim();
|
||||
while (wrapsWhole(e)) e = e.slice(1, -1).trim();
|
||||
const ops = topLevelOperators(e);
|
||||
const q = ops.findIndex((o) => o.op === '?');
|
||||
if (q >= 0) {
|
||||
// The matching `:` of the first `?`: nested ternaries in the consequent raise the count.
|
||||
let open = 0
|
||||
let open = 0;
|
||||
for (const o of ops.slice(q + 1)) {
|
||||
if (o.op === '?') open++
|
||||
if (o.op === '?') open++;
|
||||
else if (o.op === ':' && open-- === 0) {
|
||||
const a = interpolationResults(e.slice(ops[q].at + 1, o.at))
|
||||
const b = interpolationResults(e.slice(o.at + 1))
|
||||
return { literals: [...a.literals, ...b.literals], complete: a.complete && b.complete }
|
||||
const a = interpolationResults(e.slice(ops[q].at + 1, o.at));
|
||||
const b = interpolationResults(e.slice(o.at + 1));
|
||||
return { literals: [...a.literals, ...b.literals], complete: a.complete && b.complete };
|
||||
}
|
||||
}
|
||||
return { literals: [], complete: false }
|
||||
return { literals: [], complete: false };
|
||||
}
|
||||
const fallback = ops.filter((o) => o.op === '??' || o.op === '||')
|
||||
const fallback = ops.filter((o) => o.op === '??' || o.op === '||');
|
||||
if (fallback.length) {
|
||||
const operands = []
|
||||
let from = 0
|
||||
const operands = [];
|
||||
let from = 0;
|
||||
for (const o of fallback) {
|
||||
operands.push(e.slice(from, o.at))
|
||||
from = o.at + 2
|
||||
operands.push(e.slice(from, o.at));
|
||||
from = o.at + 2;
|
||||
}
|
||||
operands.push(e.slice(from))
|
||||
operands.push(e.slice(from));
|
||||
// The left operands are tested values; only the literal ones among them could be produced.
|
||||
const literals = operands.flatMap((x) => interpolationResults(x).literals)
|
||||
return { literals, complete: false }
|
||||
const literals = operands.flatMap((x) => interpolationResults(x).literals);
|
||||
return { literals, complete: false };
|
||||
}
|
||||
const m = QUOTED_RE.exec(e)
|
||||
return m ? { literals: [m[2]], complete: true } : { literals: [], complete: false }
|
||||
const m = QUOTED_RE.exec(e);
|
||||
return m ? { literals: [m[2]], complete: true } : { literals: [], complete: false };
|
||||
}
|
||||
|
||||
/** How many keys a template with only literal choices may expand to before it is read as a pattern instead. */
|
||||
const MAX_EXPANSION = 64
|
||||
const MAX_EXPANSION = 64;
|
||||
|
||||
/**
|
||||
* The references one template literal makes. Interpolations that can only
|
||||
@@ -297,91 +297,92 @@ const MAX_EXPANSION = 64
|
||||
* that interpolation fixed, under the original template's allow-list site.
|
||||
*/
|
||||
function templateRefs(file, line, template) {
|
||||
const parts = template.split(/\$\{([^}]*)\}/)
|
||||
const fixed = parts.filter((_, i) => i % 2 === 0)
|
||||
const results = parts.filter((_, i) => i % 2 === 1).map(interpolationResults)
|
||||
const literal = []
|
||||
const dynamic = []
|
||||
const build = (choices) => fixed.map((f, i) => f + (i < choices.length ? choices[i] : '')).join('')
|
||||
const size = results.reduce((n, r) => n * Math.max(r.literals.length, 1), 1)
|
||||
const parts = template.split(/\$\{([^}]*)\}/);
|
||||
const fixed = parts.filter((_, i) => i % 2 === 0);
|
||||
const results = parts.filter((_, i) => i % 2 === 1).map(interpolationResults);
|
||||
const literal = [];
|
||||
const dynamic = [];
|
||||
const build = (choices) => fixed.map((f, i) => f + (i < choices.length ? choices[i] : '')).join('');
|
||||
const size = results.reduce((n, r) => n * Math.max(r.literals.length, 1), 1);
|
||||
if (results.every((r) => r.complete) && size <= MAX_EXPANSION) {
|
||||
let keys = ['']
|
||||
let keys = [''];
|
||||
results.forEach((r, i) => {
|
||||
keys = keys.flatMap((k) => r.literals.map((lit) => k + fixed[i] + lit))
|
||||
})
|
||||
for (const k of keys) literal.push({ file, line, key: k + fixed[fixed.length - 1] })
|
||||
return { literal, dynamic }
|
||||
keys = keys.flatMap((k) => r.literals.map((lit) => k + fixed[i] + lit));
|
||||
});
|
||||
for (const k of keys) literal.push({ file, line, key: k + fixed[fixed.length - 1] });
|
||||
return { literal, dynamic };
|
||||
}
|
||||
dynamic.push({ file, line, template, pattern: templatePattern(template) })
|
||||
const raw = parts.filter((_, i) => i % 2 === 1).map((x) => `\${${x}}`)
|
||||
dynamic.push({ file, line, template, pattern: templatePattern(template) });
|
||||
const raw = parts.filter((_, i) => i % 2 === 1).map((x) => `\${${x}}`);
|
||||
results.forEach((r, i) => {
|
||||
for (const lit of r.literals) {
|
||||
const choices = raw.map((x, j) => (j === i ? lit : x))
|
||||
const derived = build(choices)
|
||||
if (!derived.includes('${')) literal.push({ file, line, key: derived })
|
||||
else dynamic.push({ file, line, template: derived, pattern: templatePattern(derived), site: template })
|
||||
const choices = raw.map((x, j) => (j === i ? lit : x));
|
||||
const derived = build(choices);
|
||||
if (!derived.includes('${')) literal.push({ file, line, key: derived });
|
||||
else dynamic.push({ file, line, template: derived, pattern: templatePattern(derived), site: template });
|
||||
}
|
||||
})
|
||||
return { literal, dynamic }
|
||||
});
|
||||
return { literal, dynamic };
|
||||
}
|
||||
|
||||
/** Every key reference in one file's source. */
|
||||
export function scanSource(text, file = '<source>') {
|
||||
const literal = []
|
||||
const dynamic = []
|
||||
const literal = [];
|
||||
const dynamic = [];
|
||||
for (const m of text.matchAll(CALL_RE)) {
|
||||
const open = m.index + m[0].length - 1
|
||||
const { quoted, templates } = literalsIn(firstArgument(text, open))
|
||||
const line = lineAt(text, m.index)
|
||||
const open = m.index + m[0].length - 1;
|
||||
const { quoted, templates } = literalsIn(firstArgument(text, open));
|
||||
const line = lineAt(text, m.index);
|
||||
for (const key of quoted) {
|
||||
if (KEY_RE.test(key)) literal.push({ file, line, key })
|
||||
if (KEY_RE.test(key)) literal.push({ file, line, key });
|
||||
// t('costs.filter.' + f): a prefix with the rest appended, read like a template.
|
||||
else if (PREFIX_RE.test(key)) dynamic.push({ file, line, template: `${key}\${…}`, pattern: templatePattern(`${key}\${…}`) })
|
||||
else if (PREFIX_RE.test(key))
|
||||
dynamic.push({ file, line, template: `${key}\${…}`, pattern: templatePattern(`${key}\${…}`) });
|
||||
}
|
||||
for (const template of templates) {
|
||||
if (!template.includes('${')) {
|
||||
if (KEY_RE.test(template)) literal.push({ file, line, key: template })
|
||||
continue
|
||||
if (KEY_RE.test(template)) literal.push({ file, line, key: template });
|
||||
continue;
|
||||
}
|
||||
const refs = templateRefs(file, line, template)
|
||||
literal.push(...refs.literal)
|
||||
dynamic.push(...refs.dynamic)
|
||||
const refs = templateRefs(file, line, template);
|
||||
literal.push(...refs.literal);
|
||||
dynamic.push(...refs.dynamic);
|
||||
}
|
||||
}
|
||||
for (const m of text.matchAll(KEY_BUILDER_RE)) {
|
||||
// Builders of other strings (cache keys) have no fixed dotted prefix and are not translation keys.
|
||||
const pattern = templatePattern(m[1])
|
||||
if (pattern) dynamic.push({ file, line: lineAt(text, m.index), template: m[1], pattern })
|
||||
const pattern = templatePattern(m[1]);
|
||||
if (pattern) dynamic.push({ file, line: lineAt(text, m.index), template: m[1], pattern });
|
||||
}
|
||||
for (const re of [HTML_PROP_RE, API_ERROR_RE, KEY_PROP_RE]) {
|
||||
for (const m of text.matchAll(re)) {
|
||||
if (KEY_RE.test(m[2])) literal.push({ file, line: lineAt(text, m.index), key: m[2] })
|
||||
if (KEY_RE.test(m[2])) literal.push({ file, line: lineAt(text, m.index), key: m[2] });
|
||||
}
|
||||
}
|
||||
return { literal, dynamic }
|
||||
return { literal, dynamic };
|
||||
}
|
||||
|
||||
/** en's keys, read from the locale sources with the shared parity tooling's reader. */
|
||||
export async function readEnKeys() {
|
||||
const { listDomainFiles, readCatalog } = await import(pathToFileURL(join(SHARED_SCRIPTS, 'i18n-catalog.mjs')).href)
|
||||
const keys = new Set()
|
||||
for (const file of listDomainFiles('en')) for (const { key } of readCatalog('en', file)) keys.add(key)
|
||||
if (keys.size === 0) throw new Error('shared/src/i18n/en holds no keys: the reader or the path is broken')
|
||||
return keys
|
||||
const { listDomainFiles, readCatalog } = await import(pathToFileURL(join(SHARED_SCRIPTS, 'i18n-catalog.mjs')).href);
|
||||
const keys = new Set();
|
||||
for (const file of listDomainFiles('en')) for (const { key } of readCatalog('en', file)) keys.add(key);
|
||||
if (keys.size === 0) throw new Error('shared/src/i18n/en holds no keys: the reader or the path is broken');
|
||||
return keys;
|
||||
}
|
||||
|
||||
export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.other`)
|
||||
export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.other`);
|
||||
|
||||
const base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, '')
|
||||
const base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, '');
|
||||
|
||||
/** How many en keys a pattern reaches, a plural group counted once. */
|
||||
export function countMatches(pattern, enKeys) {
|
||||
const reached = new Set()
|
||||
const reached = new Set();
|
||||
for (const k of enKeys) {
|
||||
if (pattern.test(k)) reached.add(k)
|
||||
else if (pattern.test(base(k))) reached.add(base(k))
|
||||
if (pattern.test(k)) reached.add(k);
|
||||
else if (pattern.test(base(k))) reached.add(base(k));
|
||||
}
|
||||
return reached.size
|
||||
return reached.size;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,85 +392,87 @@ export function countMatches(pattern, enKeys) {
|
||||
* needs, and the en keys nothing reaches.
|
||||
*/
|
||||
export function evaluate(scan, enKeys, allowed = DYNAMIC_ALLOWED) {
|
||||
const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key))
|
||||
const site = (file, template) => `${file}\n${template}`
|
||||
const allowedSites = new Set(allowed.map((a) => site(a.file, a.template)))
|
||||
const counts = new Map()
|
||||
const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key));
|
||||
const site = (file, template) => `${file}\n${template}`;
|
||||
const allowedSites = new Set(allowed.map((a) => site(a.file, a.template)));
|
||||
const counts = new Map();
|
||||
const reach = (template, pattern) => {
|
||||
if (!counts.has(template)) counts.set(template, pattern ? countMatches(pattern, enKeys) : null)
|
||||
return counts.get(template)
|
||||
}
|
||||
const unmatched = []
|
||||
const broad = []
|
||||
if (!counts.has(template)) counts.set(template, pattern ? countMatches(pattern, enKeys) : null);
|
||||
return counts.get(template);
|
||||
};
|
||||
const unmatched = [];
|
||||
const broad = [];
|
||||
for (const d of scan.dynamic) {
|
||||
const n = reach(d.template, d.pattern)
|
||||
const isAllowed = allowedSites.has(site(d.file, d.site ?? d.template))
|
||||
if (n === 0 || (n === null && !isAllowed)) unmatched.push(d)
|
||||
else if (n > MAX_IMPLICIT_MATCHES && !isAllowed) broad.push({ ...d, matches: n })
|
||||
const n = reach(d.template, d.pattern);
|
||||
const isAllowed = allowedSites.has(site(d.file, d.site ?? d.template));
|
||||
if (n === 0 || (n === null && !isAllowed)) unmatched.push(d);
|
||||
else if (n > MAX_IMPLICIT_MATCHES && !isAllowed) broad.push({ ...d, matches: n });
|
||||
}
|
||||
const usedSites = new Set(scan.dynamic.map((d) => site(d.file, d.site ?? d.template)))
|
||||
const usedSites = new Set(scan.dynamic.map((d) => site(d.file, d.site ?? d.template)));
|
||||
const stale = allowed.filter((a) => {
|
||||
if (!usedSites.has(site(a.file, a.template))) return true
|
||||
if ((a.resolves ?? []).some((k) => !hasKey(enKeys, k))) return true
|
||||
if (!usedSites.has(site(a.file, a.template))) return true;
|
||||
if ((a.resolves ?? []).some((k) => !hasKey(enKeys, k))) return true;
|
||||
// An entry for a template narrow enough to pass by itself only lengthens the list.
|
||||
const n = reach(a.template, templatePattern(a.template))
|
||||
return n !== null && n <= MAX_IMPLICIT_MATCHES
|
||||
})
|
||||
const reached = new Set([...scan.literal.map((l) => l.key), ...allowed.flatMap((a) => a.resolves ?? [])])
|
||||
const patterns = scan.dynamic.map((d) => d.pattern).filter(Boolean)
|
||||
const n = reach(a.template, templatePattern(a.template));
|
||||
return n !== null && n <= MAX_IMPLICIT_MATCHES;
|
||||
});
|
||||
const reached = new Set([...scan.literal.map((l) => l.key), ...allowed.flatMap((a) => a.resolves ?? [])]);
|
||||
const patterns = scan.dynamic.map((d) => d.pattern).filter(Boolean);
|
||||
const unused = [...enKeys].filter(
|
||||
(k) => !reached.has(k) && !reached.has(base(k)) && !patterns.some((p) => p.test(k) || p.test(base(k))),
|
||||
)
|
||||
return { missing, unmatched, broad, stale, unused }
|
||||
(k) => !reached.has(k) && !reached.has(base(k)) && !patterns.some((p) => p.test(k) || p.test(base(k)))
|
||||
);
|
||||
return { missing, unmatched, broad, stale, unused };
|
||||
}
|
||||
|
||||
export function scanTree(root = SRC) {
|
||||
if (!existsSync(root)) throw new Error(`${root} does not exist`)
|
||||
const scan = { literal: [], dynamic: [] }
|
||||
if (!existsSync(root)) throw new Error(`${root} does not exist`);
|
||||
const scan = { literal: [], dynamic: [] };
|
||||
for (const path of walk(root)) {
|
||||
const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/'))
|
||||
scan.literal.push(...found.literal)
|
||||
scan.dynamic.push(...found.dynamic)
|
||||
const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/'));
|
||||
scan.literal.push(...found.literal);
|
||||
scan.dynamic.push(...found.dynamic);
|
||||
}
|
||||
if (scan.literal.length === 0) throw new Error(`no translation key found under ${root}: the scanner is broken`)
|
||||
return scan
|
||||
if (scan.literal.length === 0) throw new Error(`no translation key found under ${root}: the scanner is broken`);
|
||||
return scan;
|
||||
}
|
||||
|
||||
// Compared by real path, so the check still runs when the script is started through a symlink.
|
||||
const isCli =
|
||||
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
Boolean(process.argv[1]) &&
|
||||
existsSync(process.argv[1]) &&
|
||||
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
try {
|
||||
const scan = scanTree()
|
||||
const enKeys = await readEnKeys()
|
||||
const { missing, unmatched, broad, stale, unused } = evaluate(scan, enKeys)
|
||||
const scan = scanTree();
|
||||
const enKeys = await readEnKeys();
|
||||
const { missing, unmatched, broad, stale, unused } = evaluate(scan, enKeys);
|
||||
for (const { file, line, key } of missing) {
|
||||
console.error(`FAIL ${file}:${line}: '${key}' is not a key in shared/src/i18n/en`)
|
||||
console.error(`FAIL ${file}:${line}: '${key}' is not a key in shared/src/i18n/en`);
|
||||
}
|
||||
for (const { file, line, template } of unmatched) {
|
||||
console.error(`FAIL ${file}:${line}: \`${template}\` matches no key in shared/src/i18n/en`)
|
||||
console.error(`FAIL ${file}:${line}: \`${template}\` matches no key in shared/src/i18n/en`);
|
||||
}
|
||||
for (const { file, line, template, matches } of broad) {
|
||||
console.error(
|
||||
`FAIL ${file}:${line}: \`${template}\` reaches ${matches} en keys, more than ${MAX_IMPLICIT_MATCHES}: ` +
|
||||
'narrow the prefix, or add a DYNAMIC_ALLOWED entry saying what bounds the value',
|
||||
)
|
||||
'narrow the prefix, or add a DYNAMIC_ALLOWED entry saying what bounds the value'
|
||||
);
|
||||
}
|
||||
for (const { file, template } of stale) {
|
||||
console.error(
|
||||
`FAIL DYNAMIC_ALLOWED lists \`${template}\` in ${file}, which that file no longer uses, whose keys en ` +
|
||||
'lacks, or which is narrow enough to pass without it: remove or fix the entry',
|
||||
)
|
||||
'lacks, or which is narrow enough to pass without it: remove or fix the entry'
|
||||
);
|
||||
}
|
||||
if (process.argv.includes('--unused')) for (const key of unused.sort()) console.log(`unused ${key}`)
|
||||
if (process.argv.includes('--unused')) for (const key of unused.sort()) console.log(`unused ${key}`);
|
||||
console.log(
|
||||
`i18n keys: ${new Set(scan.literal.map((l) => l.key)).size} literal and ${scan.dynamic.length} template ` +
|
||||
`reference(s) checked against ${enKeys.size} en keys; ${unused.length} en key(s) reached by neither ` +
|
||||
'(information, --unused lists them)',
|
||||
)
|
||||
if (missing.length || unmatched.length || broad.length || stale.length) process.exit(1)
|
||||
'(information, --unused lists them)'
|
||||
);
|
||||
if (missing.length || unmatched.length || broad.length || stale.length) process.exit(1);
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${err.message}`)
|
||||
process.exit(1)
|
||||
console.error(`FAIL ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DYNAMIC_ALLOWED,
|
||||
MAX_IMPLICIT_MATCHES,
|
||||
@@ -13,154 +13,185 @@ import {
|
||||
scanSource,
|
||||
scanTree,
|
||||
templatePattern,
|
||||
} from '../../../scripts/i18n-keys.mjs'
|
||||
} from '../../../scripts/i18n-keys.mjs';
|
||||
|
||||
// FE-I18N-KEYS-001 to FE-I18N-KEYS-015: the client key check (scripts/i18n-keys.mjs).
|
||||
|
||||
const EN = new Set(['budget.title', 'places.count', 'places.count.one', 'trips.total.other', 'trips.total.one', 'costs.filter.all'])
|
||||
const EN = new Set([
|
||||
'budget.title',
|
||||
'places.count',
|
||||
'places.count.one',
|
||||
'trips.total.other',
|
||||
'trips.total.one',
|
||||
'costs.filter.all',
|
||||
]);
|
||||
|
||||
describe('i18n key check', () => {
|
||||
it('FE-I18N-KEYS-001: finds literal keys in t, tHtml, tr, TransHtml, translateApiError and *Key props', () => {
|
||||
const { literal } = scanSource([
|
||||
"t('budget.title')",
|
||||
'props.t("places.count", { count })',
|
||||
"tHtml('a.html')",
|
||||
"tr('pdf.mapTitle')",
|
||||
'<TransHtml html="journey.text" />',
|
||||
"translateApiError(t, err, 'files.uploadError')",
|
||||
"const tabs = [{ labelKey: 'tabs.one' }]",
|
||||
'<Hint titleKey="hint.title" />',
|
||||
].join('\n'))
|
||||
const { literal } = scanSource(
|
||||
[
|
||||
"t('budget.title')",
|
||||
'props.t("places.count", { count })',
|
||||
"tHtml('a.html')",
|
||||
"tr('pdf.mapTitle')",
|
||||
'<TransHtml html="journey.text" />',
|
||||
"translateApiError(t, err, 'files.uploadError')",
|
||||
"const tabs = [{ labelKey: 'tabs.one' }]",
|
||||
'<Hint titleKey="hint.title" />',
|
||||
].join('\n')
|
||||
);
|
||||
expect(literal.map((l) => l.key)).toEqual([
|
||||
'budget.title', 'places.count', 'a.html', 'pdf.mapTitle', 'journey.text', 'files.uploadError', 'tabs.one', 'hint.title',
|
||||
])
|
||||
})
|
||||
'budget.title',
|
||||
'places.count',
|
||||
'a.html',
|
||||
'pdf.mapTitle',
|
||||
'journey.text',
|
||||
'files.uploadError',
|
||||
'tabs.one',
|
||||
'hint.title',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-002: checks both sides of a conditional key and ignores later arguments', () => {
|
||||
const { literal } = scanSource("t(open ? 'a.open' : 'a.closed', { name: 'x.y' })")
|
||||
expect(literal.map((l) => l.key)).toEqual(['a.open', 'a.closed'])
|
||||
expect(firstArgument("t(fn(a, b) ? 'x.y' : 'z.w', 1)", 1)).toBe("fn(a, b) ? 'x.y' : 'z.w'")
|
||||
})
|
||||
const { literal } = scanSource("t(open ? 'a.open' : 'a.closed', { name: 'x.y' })");
|
||||
expect(literal.map((l) => l.key)).toEqual(['a.open', 'a.closed']);
|
||||
expect(firstArgument("t(fn(a, b) ? 'x.y' : 'z.w', 1)", 1)).toBe("fn(a, b) ? 'x.y' : 'z.w'");
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-003: reads templates, appended prefixes and key builders as patterns', () => {
|
||||
const { literal, dynamic } = scanSource([
|
||||
't(`costs.filter.${f}`)',
|
||||
"t('costs.filter.' + f)",
|
||||
't(`plain.key`)',
|
||||
'export const guideKey = (id: string): string =>\n `help.guide.${id}.title`',
|
||||
'const cacheKey = (id: number): string => `${id} x`',
|
||||
"const other = notATranslation('x.y')",
|
||||
].join('\n'))
|
||||
expect(literal.map((l) => l.key)).toEqual(['plain.key'])
|
||||
expect(dynamic.map((d) => d.template)).toEqual(['costs.filter.${f}', 'costs.filter.${…}', 'help.guide.${id}.title'])
|
||||
expect(templatePattern('costs.filter.${f}')!.test('costs.filter.all')).toBe(true)
|
||||
expect(templatePattern('${opt.label}Hint')).toBeNull()
|
||||
const { literal, dynamic } = scanSource(
|
||||
[
|
||||
't(`costs.filter.${f}`)',
|
||||
"t('costs.filter.' + f)",
|
||||
't(`plain.key`)',
|
||||
'export const guideKey = (id: string): string =>\n `help.guide.${id}.title`',
|
||||
'const cacheKey = (id: number): string => `${id} x`',
|
||||
"const other = notATranslation('x.y')",
|
||||
].join('\n')
|
||||
);
|
||||
expect(literal.map((l) => l.key)).toEqual(['plain.key']);
|
||||
expect(dynamic.map((d) => d.template)).toEqual([
|
||||
'costs.filter.${f}',
|
||||
'costs.filter.${…}',
|
||||
'help.guide.${id}.title',
|
||||
]);
|
||||
expect(templatePattern('costs.filter.${f}')!.test('costs.filter.all')).toBe(true);
|
||||
expect(templatePattern('${opt.label}Hint')).toBeNull();
|
||||
// An interpolation is one key segment: costs.filter.${f} does not reach a nested costs.filter.x.y.
|
||||
expect(templatePattern('costs.filter.${f}')!.test('costs.filter.x.y')).toBe(false)
|
||||
})
|
||||
expect(templatePattern('costs.filter.${f}')!.test('costs.filter.x.y')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-004: fails a literal key en lacks and accepts a plural group by its general form', () => {
|
||||
const scan = scanSource("t('budget.addCategory'); t('places.count', { count }); t('trips.total', { count })")
|
||||
const { missing } = evaluate(scan, EN, [])
|
||||
expect(missing.map((m) => m.key)).toEqual(['budget.addCategory'])
|
||||
})
|
||||
const scan = scanSource("t('budget.addCategory'); t('places.count', { count }); t('trips.total', { count })");
|
||||
const { missing } = evaluate(scan, EN, []);
|
||||
expect(missing.map((m) => m.key)).toEqual(['budget.addCategory']);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-005: fails a template no en key matches unless it is allowed with the keys it resolves to', () => {
|
||||
const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`); t(`${opt.label}Hint`)')
|
||||
expect(evaluate(scan, EN, []).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}', '${opt.label}Hint'])
|
||||
const allowed = [{ file: '<source>', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }]
|
||||
const result = evaluate(scan, EN, allowed)
|
||||
expect(result.unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}'])
|
||||
expect(result.stale).toEqual([])
|
||||
})
|
||||
const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`); t(`${opt.label}Hint`)');
|
||||
expect(evaluate(scan, EN, []).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}', '${opt.label}Hint']);
|
||||
const allowed = [{ file: '<source>', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }];
|
||||
const result = evaluate(scan, EN, allowed);
|
||||
expect(result.unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']);
|
||||
expect(result.stale).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-006: refuses an allow-list entry nothing uses or whose keys en lacks', () => {
|
||||
const scan = scanSource('t(`${opt.label}Hint`)')
|
||||
const unused = [{ file: '<source>', template: '${gone}', because: 'test', resolves: ['budget.title'] }]
|
||||
expect(evaluate(scan, EN, unused).stale).toEqual(unused)
|
||||
const wrong = [{ file: '<source>', template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }]
|
||||
expect(evaluate(scan, EN, wrong).stale).toEqual(wrong)
|
||||
const elsewhere = [{ file: 'other.tsx', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }]
|
||||
expect(evaluate(scan, EN, elsewhere).stale).toEqual(elsewhere)
|
||||
expect(evaluate(scan, EN, elsewhere).unmatched.map((u) => u.template)).toEqual(['${opt.label}Hint'])
|
||||
})
|
||||
const scan = scanSource('t(`${opt.label}Hint`)');
|
||||
const unused = [{ file: '<source>', template: '${gone}', because: 'test', resolves: ['budget.title'] }];
|
||||
expect(evaluate(scan, EN, unused).stale).toEqual(unused);
|
||||
const wrong = [{ file: '<source>', template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }];
|
||||
expect(evaluate(scan, EN, wrong).stale).toEqual(wrong);
|
||||
const elsewhere = [
|
||||
{ file: 'other.tsx', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] },
|
||||
];
|
||||
expect(evaluate(scan, EN, elsewhere).stale).toEqual(elsewhere);
|
||||
expect(evaluate(scan, EN, elsewhere).unmatched.map((u) => u.template)).toEqual(['${opt.label}Hint']);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-007: reports en keys neither a literal nor a pattern reaches', () => {
|
||||
const scan = scanSource("t('budget.title'); t('places.count', { count }); t(`costs.filter.${f}`)")
|
||||
expect(evaluate(scan, EN, []).unused).toEqual(['trips.total.other', 'trips.total.one'])
|
||||
})
|
||||
const scan = scanSource("t('budget.title'); t('places.count', { count }); t(`costs.filter.${f}`)");
|
||||
expect(evaluate(scan, EN, []).unused).toEqual(['trips.total.other', 'trips.total.one']);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-008: every allow-list entry names its file and a reason, and a prefix-less one its keys', () => {
|
||||
for (const entry of DYNAMIC_ALLOWED) {
|
||||
expect(entry.file).toMatch(/\.tsx?$/)
|
||||
expect(entry.because.length).toBeGreaterThan(20)
|
||||
if (templatePattern(entry.template) === null) expect('resolves' in entry && entry.resolves.length).toBeGreaterThan(0)
|
||||
expect(entry.file).toMatch(/\.tsx?$/);
|
||||
expect(entry.because.length).toBeGreaterThan(20);
|
||||
if (templatePattern(entry.template) === null)
|
||||
expect('resolves' in entry && entry.resolves.length).toBeGreaterThan(0);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-009: fails closed on a missing source tree or one without any key', () => {
|
||||
expect(() => scanTree(join(tmpdir(), 'trek-i18n-keys-does-not-exist'))).toThrow(/does not exist/)
|
||||
const empty = mkdtempSync(join(tmpdir(), 'trek-i18n-keys-'))
|
||||
expect(() => scanTree(join(tmpdir(), 'trek-i18n-keys-does-not-exist'))).toThrow(/does not exist/);
|
||||
const empty = mkdtempSync(join(tmpdir(), 'trek-i18n-keys-'));
|
||||
try {
|
||||
expect(() => scanTree(empty)).toThrow(/scanner is broken/)
|
||||
expect(() => scanTree(empty)).toThrow(/scanner is broken/);
|
||||
} finally {
|
||||
rmSync(empty, { recursive: true, force: true })
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-010: the client source names no key en lacks today', async () => {
|
||||
const enKeys = await readEnKeys()
|
||||
const { missing, unmatched, broad, stale } = evaluate(scanTree(), enKeys)
|
||||
expect(missing).toEqual([])
|
||||
expect(unmatched).toEqual([])
|
||||
expect(broad).toEqual([])
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
const enKeys = await readEnKeys();
|
||||
const { missing, unmatched, broad, stale } = evaluate(scanTree(), enKeys);
|
||||
expect(missing).toEqual([]);
|
||||
expect(unmatched).toEqual([]);
|
||||
expect(broad).toEqual([]);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
// A table of en keys wider than the implicit bound: wide.k0 to wide.k<MAX>.
|
||||
const WIDE = new Set([...EN, ...Array.from({ length: MAX_IMPLICIT_MATCHES + 1 }, (_, i) => `wide.k${i}`)])
|
||||
const WIDE = new Set([...EN, ...Array.from({ length: MAX_IMPLICIT_MATCHES + 1 }, (_, i) => `wide.k${i}`)]);
|
||||
|
||||
it('FE-I18N-KEYS-011: counts a plural group once when measuring what a template reaches', () => {
|
||||
expect(countMatches(templatePattern('trips.${x}')!, EN)).toBe(1)
|
||||
expect(countMatches(templatePattern('wide.${x}')!, WIDE)).toBe(MAX_IMPLICIT_MATCHES + 1)
|
||||
})
|
||||
expect(countMatches(templatePattern('trips.${x}')!, EN)).toBe(1);
|
||||
expect(countMatches(templatePattern('wide.${x}')!, WIDE)).toBe(MAX_IMPLICIT_MATCHES + 1);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-012: fails a template wider than the bound unless its file has an entry for it', () => {
|
||||
const scan = scanSource('t(`wide.${x}`); t(`costs.filter.${f}`)', 'a.tsx')
|
||||
const { broad, unmatched } = evaluate(scan, WIDE, [])
|
||||
expect(broad.map((b) => [b.template, b.matches])).toEqual([['wide.${x}', MAX_IMPLICIT_MATCHES + 1]])
|
||||
expect(unmatched).toEqual([])
|
||||
const otherFile = [{ file: 'b.tsx', template: 'wide.${x}', because: 'test' }]
|
||||
expect(evaluate(scan, WIDE, otherFile).broad).toHaveLength(1)
|
||||
const ownFile = [{ file: 'a.tsx', template: 'wide.${x}', because: 'test' }]
|
||||
expect(evaluate(scan, WIDE, ownFile)).toMatchObject({ broad: [], stale: [] })
|
||||
})
|
||||
const scan = scanSource('t(`wide.${x}`); t(`costs.filter.${f}`)', 'a.tsx');
|
||||
const { broad, unmatched } = evaluate(scan, WIDE, []);
|
||||
expect(broad.map((b) => [b.template, b.matches])).toEqual([['wide.${x}', MAX_IMPLICIT_MATCHES + 1]]);
|
||||
expect(unmatched).toEqual([]);
|
||||
const otherFile = [{ file: 'b.tsx', template: 'wide.${x}', because: 'test' }];
|
||||
expect(evaluate(scan, WIDE, otherFile).broad).toHaveLength(1);
|
||||
const ownFile = [{ file: 'a.tsx', template: 'wide.${x}', because: 'test' }];
|
||||
expect(evaluate(scan, WIDE, ownFile)).toMatchObject({ broad: [], stale: [] });
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-013: refuses an entry for a narrow template, and an allowed template that matches nothing', () => {
|
||||
const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`)', 'a.tsx')
|
||||
const narrow = [{ file: 'a.tsx', template: 'costs.filter.${f}', because: 'test' }]
|
||||
expect(evaluate(scan, EN, narrow).stale).toEqual(narrow)
|
||||
const gone = [{ file: 'a.tsx', template: 'gone.prefix.${x}', because: 'test' }]
|
||||
expect(evaluate(scan, EN, gone).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}'])
|
||||
})
|
||||
const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`)', 'a.tsx');
|
||||
const narrow = [{ file: 'a.tsx', template: 'costs.filter.${f}', because: 'test' }];
|
||||
expect(evaluate(scan, EN, narrow).stale).toEqual(narrow);
|
||||
const gone = [{ file: 'a.tsx', template: 'gone.prefix.${x}', because: 'test' }];
|
||||
expect(evaluate(scan, EN, gone).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']);
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-014: checks each literal branch of a template as a key, so a typo in one fails', () => {
|
||||
const scan = scanSource("t(`costs.filter.${on ? 'all' : 'alll'}`)", 'a.tsx')
|
||||
expect(scan.dynamic).toEqual([])
|
||||
expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.all', 'costs.filter.alll'])
|
||||
expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.alll'])
|
||||
const scan = scanSource("t(`costs.filter.${on ? 'all' : 'alll'}`)", 'a.tsx');
|
||||
expect(scan.dynamic).toEqual([]);
|
||||
expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.all', 'costs.filter.alll']);
|
||||
expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.alll']);
|
||||
// Nested ternaries, parentheses and optional chaining in the condition; a literal in a comparison is no result.
|
||||
expect(interpolationResults("(a?.b === 'x') ? 'p' : c ? ('q') : 'r'")).toEqual({ literals: ['p', 'q', 'r'], complete: true })
|
||||
expect(interpolationResults("fn('a.b')")).toEqual({ literals: [], complete: false })
|
||||
})
|
||||
expect(interpolationResults("(a?.b === 'x') ? 'p' : c ? ('q') : 'r'")).toEqual({
|
||||
literals: ['p', 'q', 'r'],
|
||||
complete: true,
|
||||
});
|
||||
expect(interpolationResults("fn('a.b')")).toEqual({ literals: [], complete: false });
|
||||
});
|
||||
|
||||
it('FE-I18N-KEYS-015: keeps a template with data in it a pattern and still checks its literal fallback', () => {
|
||||
const scan = scanSource("t(`costs.filter.${role ?? 'nope'}`); t(`budget.${p === 'x' ? 'title' : p}`)", 'a.tsx')
|
||||
expect(scan.dynamic.map((d) => d.template)).toEqual(["costs.filter.${role ?? 'nope'}", "budget.${p === 'x' ? 'title' : p}"])
|
||||
expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.nope', 'budget.title'])
|
||||
expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.nope'])
|
||||
const scan = scanSource("t(`costs.filter.${role ?? 'nope'}`); t(`budget.${p === 'x' ? 'title' : p}`)", 'a.tsx');
|
||||
expect(scan.dynamic.map((d) => d.template)).toEqual([
|
||||
"costs.filter.${role ?? 'nope'}",
|
||||
"budget.${p === 'x' ? 'title' : p}",
|
||||
]);
|
||||
expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.nope', 'budget.title']);
|
||||
expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.nope']);
|
||||
// A derived pattern is judged under its source template's allow-list entry.
|
||||
const two = scanSource("t(`wide.${a ?? 'k0'}.${b}`)", 'a.tsx')
|
||||
expect(two.dynamic.map((d) => d.site)).toEqual([undefined, "wide.${a ?? 'k0'}.${b}"])
|
||||
})
|
||||
})
|
||||
const two = scanSource("t(`wide.${a ?? 'k0'}.${b}`)", 'a.tsx');
|
||||
expect(two.dynamic.map((d) => d.site)).toEqual([undefined, "wide.${a ?? 'k0'}.${b}"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user