mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
SceneShotList and SceneOptions render the project from a gev-scenes store instead of scenePresentation building rows with createElement. SceneControls stays the owner: it publishes the director's state and attaches the row actions, and a late destroy cannot detach a newer owner. Rows keep their identity across selection; a rename keeps its typed text across republishing and ends with its row. The select still changes its choice only when its options are rendered. Vue keeps whitespace in every component, so Prettier formats single-file components with strict whitespace sensitivity, and the test loader parses templates with the same options as the browser build. The i18n check reads view text without its interpolations, whose t() calls are scanned with the sources.
651 lines
22 KiB
JavaScript
651 lines
22 KiB
JavaScript
// Keeps the locale catalogs in step with the code.
|
||
//
|
||
// npm run i18n check every catalog and find unmarked UI text
|
||
// npm run i18n -- --prune also drop entries the code no longer uses
|
||
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
|
||
//
|
||
// A translation left as the English fails unless src/i18n/same-as-english.json
|
||
// lists it.
|
||
//
|
||
// Messages are the English literals passed to t(), tn() and msg() in src/,
|
||
// plus the markup opted in with data-i18n* in index.html, src/ui/templates and
|
||
// the Vue views.
|
||
// It also lists UI text that is not marked at all (see findUnmarked).
|
||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
|
||
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
|
||
|
||
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
|
||
const CALL = new RegExp(
|
||
String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
|
||
'g',
|
||
);
|
||
|
||
function unquote(literal) {
|
||
const body = literal.slice(1, -1);
|
||
return body.replace(
|
||
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/gs,
|
||
(_, esc) => {
|
||
if (esc[0] === 'u' || esc[0] === 'x') {
|
||
return String.fromCodePoint(parseInt(esc.replace(/[ux{}]/g, ''), 16));
|
||
}
|
||
return { n: '\n', t: '\t', r: '\r' }[esc] ?? esc;
|
||
},
|
||
);
|
||
}
|
||
|
||
const ENTITIES = {
|
||
amp: '&',
|
||
lt: '<',
|
||
gt: '>',
|
||
quot: '"',
|
||
apos: "'",
|
||
nbsp: ' ',
|
||
};
|
||
function decodeHtml(text) {
|
||
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => {
|
||
if (name[0] === '#') {
|
||
const hex = name[1] === 'x' || name[1] === 'X';
|
||
return String.fromCodePoint(
|
||
parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10),
|
||
);
|
||
}
|
||
return ENTITIES[name.toLowerCase()] ?? all;
|
||
});
|
||
}
|
||
|
||
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
|
||
|
||
function trackedFiles(...pathspecs) {
|
||
return (
|
||
execFileSync(
|
||
'git',
|
||
[
|
||
'ls-files',
|
||
'--cached',
|
||
'--others',
|
||
'--exclude-standard',
|
||
'--',
|
||
...pathspecs,
|
||
],
|
||
{
|
||
cwd: ROOT,
|
||
encoding: 'utf8',
|
||
},
|
||
)
|
||
.split('\n')
|
||
.filter(Boolean)
|
||
// The index still lists a file moved or deleted without git mv / git rm.
|
||
.filter((file) => existsSync(path.join(ROOT, file)))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Every message the code can ask for.
|
||
* @returns {Map<string, {plural: boolean, one?: string, where: string}>}
|
||
*/
|
||
export function extractMessages() {
|
||
const messages = new Map();
|
||
const add = (text, where, plural = null) => {
|
||
if (!text) return;
|
||
const known = messages.get(text);
|
||
if (known && Boolean(known.plural) !== Boolean(plural)) {
|
||
throw new Error(
|
||
`${where}: "${text}" is used both as plural and plain text (also ${known.where})`,
|
||
);
|
||
}
|
||
if (!known)
|
||
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
|
||
};
|
||
|
||
const sources = sourceFiles();
|
||
for (const file of sources) {
|
||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||
for (const match of source.matchAll(CALL)) {
|
||
const line = source.slice(0, match.index).split('\n').length;
|
||
const where = `${file}:${line}`;
|
||
if (match[1]) {
|
||
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
|
||
} else if (match[4]) {
|
||
if (match[4][0] === '`' && match[4].includes('${')) continue;
|
||
add(unquote(match[4]), where);
|
||
} else {
|
||
add(unquote(match[6]), where, { one: unquote(match[5]) });
|
||
}
|
||
}
|
||
}
|
||
|
||
const markup = markupFiles();
|
||
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
|
||
for (const file of markup) {
|
||
const html = readMarkup(file);
|
||
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
|
||
const where = `${file} <${tag}>`;
|
||
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
|
||
const context = attrs.match(/\sdata-i18n-context="([^"]*)"/);
|
||
const message = normalize(decodeHtml(text));
|
||
add(context ? `${decodeHtml(context[1])}|${message}` : message, where);
|
||
}
|
||
for (const [, name] of attrs.matchAll(
|
||
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
|
||
)) {
|
||
const value = attrs.match(new RegExp(String.raw`\s${name}="([^"]*)"`));
|
||
if (value) add(decodeHtml(value[1]), where);
|
||
else
|
||
throw new Error(
|
||
`${where}: data-i18n-${name} without a ${name} attribute`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return messages;
|
||
}
|
||
|
||
const hasWords = (text) => /[A-Za-z]{2,}/.test(text);
|
||
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
||
|
||
const VOID_TAGS = new Set(
|
||
'area base br col embed hr img input link meta source track wbr'.split(' '),
|
||
);
|
||
const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
|
||
|
||
/**
|
||
* User-visible markup text without data-i18n*. Skipped, with everything
|
||
* inside: translate="no" (text that stays as is, as in HTML),
|
||
* data-i18n-runtime (code sets this text through t()), and icons
|
||
* (aria-hidden, Material Symbols).
|
||
*/
|
||
export function unmarkedMarkup(file, html) {
|
||
const found = [];
|
||
const source = html.replace(/<!--[\s\S]*?-->|<![^>]*>/g, (c) =>
|
||
c.replace(/[^\n]/g, ' '),
|
||
);
|
||
const TOKEN =
|
||
/<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
|
||
const stack = [];
|
||
const skipping = () => stack.at(-1)?.skip === true;
|
||
for (const match of source.matchAll(TOKEN)) {
|
||
const [, closing, rawTag, attrs = '', selfClosing, text] = match;
|
||
if (text !== undefined) {
|
||
const top = stack.at(-1);
|
||
const value = normalize(decodeHtml(text));
|
||
if (!skipping() && hasWords(value) && !top?.marked)
|
||
found.push({
|
||
where: `${file}:${lineAt(source, match.index)}`,
|
||
text: value,
|
||
});
|
||
continue;
|
||
}
|
||
const tag = rawTag.toLowerCase();
|
||
if (closing) {
|
||
const at = stack.map((entry) => entry.tag).lastIndexOf(tag);
|
||
if (at >= 0) stack.length = at;
|
||
continue;
|
||
}
|
||
const skip =
|
||
skipping() ||
|
||
SKIP_TAGS.has(tag) ||
|
||
/\stranslate="no"/.test(attrs) ||
|
||
/\sdata-i18n-runtime\b/.test(attrs) ||
|
||
/\saria-hidden="true"/.test(attrs) ||
|
||
/class="[^"]*material-symbols/.test(attrs);
|
||
if (!skip) {
|
||
for (const [, name, value] of attrs.matchAll(
|
||
/\s(title|aria-label|placeholder)="([^"]*)"/g,
|
||
)) {
|
||
if (
|
||
hasWords(value) &&
|
||
!new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs)
|
||
)
|
||
found.push({
|
||
where: `${file}:${lineAt(source, match.index)}`,
|
||
text: `${name}="${decodeHtml(value)}"`,
|
||
});
|
||
}
|
||
}
|
||
if (!VOID_TAGS.has(tag) && !selfClosing)
|
||
stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) });
|
||
}
|
||
return found;
|
||
}
|
||
|
||
// Where a string becomes UI text. Each pattern ends where the value starts.
|
||
const JS_SINKS = [
|
||
/\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g,
|
||
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
|
||
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
|
||
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
|
||
// Also showToast?.(…) and this._showToast(…).
|
||
/\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g,
|
||
// `status` values are often codes ('ok', 'auth-failed'); visibleText drops
|
||
// those, so only readable text is reported. A name right after `?` is a
|
||
// ternary branch (`ok ? status : 'Fallback'`), not a property.
|
||
/(?:^|[{,\s])(?<!\?\s*)(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm,
|
||
];
|
||
// Layer status set by assignment: this._error = …, stats.lastError = …,
|
||
// error = … (a layer factory keeps its status in `let error`). Not checked:
|
||
// the declaration itself (`const error = …`, `let status = …`) and a default
|
||
// value in a parameter list or destructuring (see defaultValue).
|
||
const ASSIGNMENT_SINK =
|
||
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|lastError|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g;
|
||
JS_SINKS.push(ASSIGNMENT_SINK);
|
||
|
||
/**
|
||
* Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`,
|
||
* `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its
|
||
* value ends at `,` or a closing bracket. An assignment statement ends at `;`.
|
||
*/
|
||
function defaultValue(source, nameStart, valueStart) {
|
||
const before = source.slice(0, nameStart).trimEnd().at(-1);
|
||
if (!before || !'([{,'.includes(before)) return false;
|
||
const end = valueStart + expressionAt(source, valueStart).length;
|
||
const after = source.slice(end).trimStart()[0];
|
||
return Boolean(after) && ',)]}'.includes(after);
|
||
}
|
||
const MARKERS = /\b(?:t|tn|msg)\(/y;
|
||
|
||
/** The expression starting at `start`, up to its end at bracket depth 0. */
|
||
function expressionAt(source, start) {
|
||
let depth = 0;
|
||
let i = start;
|
||
for (; i < source.length; i++) {
|
||
const c = source[i];
|
||
if (c === "'" || c === '"' || c === '`') {
|
||
i = skipString(source, i);
|
||
continue;
|
||
}
|
||
if ('([{'.includes(c)) depth++;
|
||
else if (')]}'.includes(c)) {
|
||
if (depth === 0) break;
|
||
depth--;
|
||
} else if (depth === 0 && (c === ';' || c === ',')) break;
|
||
else if (depth === 0 && c === '\n') {
|
||
const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? '';
|
||
const before = source.slice(start, i).trimEnd().at(-1) ?? '';
|
||
if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before))
|
||
break;
|
||
}
|
||
}
|
||
return source.slice(start, i);
|
||
}
|
||
|
||
function skipString(source, i) {
|
||
const quote = source[i];
|
||
for (let j = i + 1; j < source.length; j++) {
|
||
if (source[j] === '\\') j++;
|
||
else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') {
|
||
let depth = 1;
|
||
for (j += 2; j < source.length && depth; j++) {
|
||
if (source[j] === '{') depth++;
|
||
else if (source[j] === '}') depth--;
|
||
else if ('\'"`'.includes(source[j])) j = skipString(source, j);
|
||
}
|
||
j--;
|
||
} else if (source[j] === quote) return j;
|
||
else if (quote !== '`' && source[j] === '\n') return j;
|
||
}
|
||
return source.length;
|
||
}
|
||
|
||
/** String literals that sit in value position: not inside any call or index,
|
||
* not compared, and not already passed to t/tn/msg. */
|
||
function valueLiterals(expression) {
|
||
const literals = [];
|
||
let callDepth = 0;
|
||
const stack = [];
|
||
for (let i = 0; i < expression.length; i++) {
|
||
const c = expression[i];
|
||
if (c === '(' || c === '[') {
|
||
const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i));
|
||
stack.push(isCall);
|
||
if (isCall) callDepth++;
|
||
continue;
|
||
}
|
||
if (c === ')' || c === ']') {
|
||
if (stack.pop()) callDepth--;
|
||
continue;
|
||
}
|
||
MARKERS.lastIndex = i;
|
||
if (MARKERS.test(expression)) {
|
||
stack.push(true);
|
||
callDepth++;
|
||
i = MARKERS.lastIndex - 1;
|
||
continue;
|
||
}
|
||
if (c !== "'" && c !== '"' && c !== '`') continue;
|
||
const end = skipString(expression, i);
|
||
const literal = expression.slice(i, end + 1);
|
||
const before = expression.slice(0, i).trimEnd();
|
||
const after = expression.slice(end + 1).trimStart();
|
||
const compared =
|
||
/(===|!==|==|!=|\bcase|\bin)$/.test(before) ||
|
||
/^(===|!==|==|!=|in\b)/.test(after);
|
||
if (callDepth === 0 && !compared) literals.push(literal);
|
||
i = end;
|
||
}
|
||
return literals;
|
||
}
|
||
|
||
/** The text a literal shows: static template parts, HTML tags removed. */
|
||
function visibleText(literal, html) {
|
||
let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
|
||
if (html) {
|
||
const attrs = [
|
||
...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g),
|
||
].map((m) => m[1]);
|
||
body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '));
|
||
}
|
||
const text = normalize(body);
|
||
if (!hasWords(text)) return null;
|
||
if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS
|
||
if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts
|
||
if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null;
|
||
return text;
|
||
}
|
||
|
||
/** Blank out comments, keeping strings and line numbers intact. */
|
||
function withoutComments(source) {
|
||
let out = '';
|
||
for (let i = 0; i < source.length; i++) {
|
||
const c = source[i];
|
||
if (c === "'" || c === '"' || c === '`') {
|
||
const end = skipString(source, i);
|
||
out += source.slice(i, end + 1);
|
||
i = end;
|
||
} else if (c === '/' && source[i + 1] === '/') {
|
||
const end = source.indexOf('\n', i);
|
||
const stop = end < 0 ? source.length : end;
|
||
out += ' '.repeat(stop - i);
|
||
i = stop - 1;
|
||
} else if (c === '/' && source[i + 1] === '*') {
|
||
const end = source.indexOf('*/', i + 2);
|
||
const stop = end < 0 ? source.length : end + 2;
|
||
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
|
||
i = stop - 1;
|
||
} else out += c;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function unmarkedScript(file, original) {
|
||
const found = [];
|
||
const lines = original.split('\n');
|
||
const source = withoutComments(original);
|
||
const ignored = (line) =>
|
||
/i18n-ignore/.test(lines[line - 1] ?? '') ||
|
||
/i18n-ignore/.test(lines[line - 2] ?? '');
|
||
for (const sink of JS_SINKS) {
|
||
sink.lastIndex = 0;
|
||
for (const match of source.matchAll(sink)) {
|
||
const start = match.index + match[0].length;
|
||
const line = lineAt(source, start);
|
||
if (ignored(line)) continue;
|
||
if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start))
|
||
continue;
|
||
const html = /HTML/.test(match[0]);
|
||
for (const literal of valueLiterals(expressionAt(source, start))) {
|
||
const text = visibleText(literal, html);
|
||
if (text) found.push({ where: `${file}:${line}`, text });
|
||
}
|
||
}
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* UI text that is not marked for translation: markup without data-i18n* and
|
||
* string literals that reach the page without t(), tn() or msg().
|
||
* Opt-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line.
|
||
* @returns {{where: string, text: string}[]}
|
||
*/
|
||
export function findUnmarked() {
|
||
const found = [];
|
||
for (const file of markupFiles())
|
||
found.push(...unmarkedMarkup(file, readMarkup(file)));
|
||
for (const file of sourceFiles().filter(
|
||
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
|
||
))
|
||
found.push(
|
||
...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||
);
|
||
const seen = new Set();
|
||
return found.filter(({ where, text }) => {
|
||
const key = `${where}\0${text}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/** Static markup opted in with data-i18n*: the page, its templates and views. */
|
||
function markupFiles() {
|
||
return trackedFiles(
|
||
'index.html',
|
||
'src/ui/templates/*.html',
|
||
'src/ui/views/*.vue',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A file's markup; for a single-file component, its template block without
|
||
* interpolations, which are code: their t() calls are read with the sources.
|
||
*/
|
||
function readMarkup(file) {
|
||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||
if (!file.endsWith('.vue')) return source;
|
||
const start = source.indexOf('<template>');
|
||
const end = source.lastIndexOf('</template>');
|
||
if (start === -1) return '';
|
||
return source
|
||
.slice(start + '<template>'.length, end)
|
||
.replace(/\{\{[\s\S]*?\}\}/g, '');
|
||
}
|
||
|
||
// Text that never reaches the page as UI: tool results and descriptions for
|
||
// AI clients (MCP and the voice model).
|
||
const NOT_UI = [/^src\/tools\//];
|
||
|
||
function sourceFiles() {
|
||
return trackedFiles(
|
||
'src/*.js',
|
||
'src/*.mjs',
|
||
'src/**/*.js',
|
||
'src/**/*.mjs',
|
||
'src/**/*.vue',
|
||
).filter(
|
||
(file) =>
|
||
!file.startsWith('src/i18n/') &&
|
||
!/\.test\.mjs$|\/testSupport\//.test(file),
|
||
);
|
||
}
|
||
|
||
const placeholders = (text) =>
|
||
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
|
||
.map((m) => m[1])
|
||
.sort()
|
||
.join(',');
|
||
|
||
export function catalogPath(locale) {
|
||
return path.join(LOCALES_DIR, `${locale}.json`);
|
||
}
|
||
|
||
export function readCatalog(locale) {
|
||
return JSON.parse(readFileSync(catalogPath(locale), 'utf8'));
|
||
}
|
||
|
||
export function shippedLocales() {
|
||
return trackedFiles('src/i18n/locales/*.json').map((file) =>
|
||
path.basename(file, '.json'),
|
||
);
|
||
}
|
||
|
||
/** Messages a locale keeps exactly as in English, on purpose. */
|
||
export function readSameAsEnglish(locale) {
|
||
const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8'));
|
||
return new Set(lists[locale] || []);
|
||
}
|
||
|
||
/** The English a message shows: its text without the "context|" prefix. */
|
||
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
|
||
|
||
/** A translation with nothing to show: t() falls back to English for it. */
|
||
const blank = (value) => value.trim() === '';
|
||
|
||
/** Words to read, beyond {placeholders}, codes and punctuation. */
|
||
const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, ''));
|
||
|
||
const STILL_ENGLISH = (text, locale) =>
|
||
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`;
|
||
|
||
/**
|
||
* Compare one catalog with the extracted messages.
|
||
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
|
||
* @returns {{missing: string[], stale: string[], errors: string[]}}
|
||
*/
|
||
export function checkCatalog(
|
||
locale,
|
||
catalog,
|
||
messages,
|
||
sameAsEnglish = readSameAsEnglish(locale),
|
||
) {
|
||
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||
.pluralCategories;
|
||
const missing = [];
|
||
const errors = [];
|
||
for (const [text, info] of messages) {
|
||
if (!Object.hasOwn(catalog, text)) {
|
||
missing.push(text);
|
||
continue;
|
||
}
|
||
const entry = catalog[text];
|
||
if (info.plural) {
|
||
if (!entry || typeof entry !== 'object') {
|
||
errors.push(`"${text}" needs plural forms ${categories.join('/')}`);
|
||
continue;
|
||
}
|
||
const english = new Set([info.one, englishOf(text)]);
|
||
const stillEnglish = [];
|
||
for (const category of categories) {
|
||
const form = entry[category];
|
||
if (typeof form !== 'string')
|
||
errors.push(`"${text}" lacks the "${category}" form`);
|
||
else if (blank(form))
|
||
errors.push(`"${text}" [${category}] is empty: translate it`);
|
||
else if (placeholders(form) !== placeholders(text))
|
||
errors.push(`"${text}" [${category}] placeholders differ: ${form}`);
|
||
else if (english.has(form) && hasLetters(form))
|
||
stillEnglish.push(category);
|
||
}
|
||
if (stillEnglish.length && !sameAsEnglish.has(text))
|
||
errors.push(
|
||
`${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`,
|
||
);
|
||
} else if (typeof entry !== 'string') {
|
||
errors.push(`"${text}" must be a string`);
|
||
} else if (blank(entry)) {
|
||
errors.push(`"${text}" is empty: translate it`);
|
||
} else if (placeholders(entry) !== placeholders(text)) {
|
||
errors.push(`"${text}" placeholders differ: ${entry}`);
|
||
} else if (
|
||
entry === englishOf(text) &&
|
||
hasLetters(entry) &&
|
||
!sameAsEnglish.has(text)
|
||
) {
|
||
errors.push(STILL_ENGLISH(text, locale));
|
||
}
|
||
}
|
||
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
|
||
return { missing, stale, errors };
|
||
}
|
||
|
||
/**
|
||
* What `--missing <locale>` prints: every missing entry, ready to translate.
|
||
* A template, not a translation: the check rejects a string left empty and a
|
||
* plural form left as the English.
|
||
*/
|
||
export function missingTemplate(locale, catalog, messages) {
|
||
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||
.pluralCategories;
|
||
const out = {};
|
||
for (const text of checkCatalog(locale, catalog, messages, new Set())
|
||
.missing) {
|
||
const info = messages.get(text);
|
||
out[text] = info.plural
|
||
? Object.fromEntries(
|
||
categories.map((category) => [
|
||
category,
|
||
category === 'one' ? info.one : text,
|
||
]),
|
||
)
|
||
: '';
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function main(args) {
|
||
const messages = extractMessages();
|
||
const missingFor = args.includes('--missing')
|
||
? args[args.indexOf('--missing') + 1]
|
||
: null;
|
||
if (missingFor) {
|
||
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
|
||
console.log(JSON.stringify(out, null, 2));
|
||
return 0;
|
||
}
|
||
let failed = false;
|
||
for (const locale of shippedLocales()) {
|
||
let catalog = readCatalog(locale);
|
||
const { missing, stale, errors } = checkCatalog(locale, catalog, messages);
|
||
if (args.includes('--prune') && stale.length) {
|
||
catalog = Object.fromEntries(
|
||
Object.entries(catalog).filter(([text]) => messages.has(text)),
|
||
);
|
||
writeFileSync(
|
||
catalogPath(locale),
|
||
`${JSON.stringify(catalog, null, 2)}\n`,
|
||
);
|
||
console.log(`${locale}: pruned ${stale.length} unused entries`);
|
||
} else if (stale.length) {
|
||
failed = true;
|
||
console.log(
|
||
`${locale}: ${stale.length} entries the code no longer uses (npm run i18n -- --prune):`,
|
||
);
|
||
for (const text of stale) console.log(` ${JSON.stringify(text)}`);
|
||
}
|
||
if (missing.length) {
|
||
failed = true;
|
||
console.log(
|
||
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`,
|
||
);
|
||
for (const text of missing)
|
||
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
|
||
}
|
||
for (const error of errors) {
|
||
failed = true;
|
||
console.log(`${locale}: ${error}`);
|
||
}
|
||
if (!missing.length && !stale.length && !errors.length) {
|
||
console.log(`${locale}: ${messages.size} messages, complete`);
|
||
}
|
||
}
|
||
const unmarked = findUnmarked();
|
||
if (unmarked.length) {
|
||
failed = true;
|
||
console.log(
|
||
`${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`,
|
||
);
|
||
for (const { where, text } of unmarked)
|
||
console.log(` ${where} ${JSON.stringify(text)}`);
|
||
}
|
||
return failed ? 1 : 0;
|
||
}
|
||
|
||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||
process.exitCode = main(process.argv.slice(2));
|
||
}
|