mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
389 lines
11 KiB
JavaScript
389 lines
11 KiB
JavaScript
/**
|
|
* Cross-locale dictionary linter: each dict in lib/i18n/ is checked against
|
|
* ALL others (key parity, empty values, placeholders), warn-only.
|
|
* Full semantics and options: web/eslint-plugins/README.md.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import tseslint from "typescript-eslint";
|
|
|
|
const PLACEHOLDER = /\{(\w+)\}/g;
|
|
|
|
/** Parse a TS source string into an ESTree Program (syntax only). */
|
|
function parseTs(code, filename) {
|
|
const result = tseslint.parser.parseForESLint(code, {
|
|
filePath: filename,
|
|
ecmaVersion: "latest",
|
|
sourceType: "module",
|
|
});
|
|
return result.ast;
|
|
}
|
|
|
|
/** Unwrap `as const` / `satisfies` wrappers around a value node. */
|
|
function unwrap(node) {
|
|
while (
|
|
node &&
|
|
(node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression")
|
|
) {
|
|
node = node.expression;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* The exported dict `export const <name>: Dict = {...}`: the declarator id
|
|
* (for reporting) and the object literal, or null.
|
|
*/
|
|
function findDictExport(ast) {
|
|
for (const node of ast.body) {
|
|
if (node.type !== "ExportNamedDeclaration") continue;
|
|
const decl = node.declaration;
|
|
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
const d = decl.declarations[0];
|
|
if (!d || d.id.type !== "Identifier") continue;
|
|
const init = unwrap(d.init);
|
|
if (init && init.type === "ObjectExpression") {
|
|
return { id: d.id, object: init };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findBaseLocale(ast) {
|
|
for (const node of ast.body) {
|
|
if (node.type !== "ExportNamedDeclaration") continue;
|
|
const decl = node.declaration;
|
|
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
for (const d of decl.declarations) {
|
|
if (d.id.type !== "Identifier" || d.id.name !== "BASE_LOCALE") {
|
|
continue;
|
|
}
|
|
const init = unwrap(d.init);
|
|
if (isStringLiteral(init)) return init.value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** The `LOCALES` list exported from `dict.ts`, or null. */
|
|
function findLocalesList(ast) {
|
|
for (const node of ast.body) {
|
|
if (node.type !== "ExportNamedDeclaration") continue;
|
|
const decl = node.declaration;
|
|
if (!decl || decl.type !== "VariableDeclaration") continue;
|
|
for (const d of decl.declarations) {
|
|
if (d.id.type !== "Identifier" || d.id.name !== "LOCALES") continue;
|
|
const init = unwrap(d.init);
|
|
if (init && init.type === "ArrayExpression") {
|
|
return init.elements
|
|
.map((el) => (isStringLiteral(el) ? el.value : null))
|
|
.filter((v) => typeof v === "string");
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* String literal node. The parser emits `Literal` in some ESTree versions and
|
|
* `StringLiteral` in others; accept both.
|
|
*/
|
|
function isStringLiteral(node) {
|
|
if (!node) return false;
|
|
if (node.type === "StringLiteral") return true;
|
|
return node.type === "Literal" && typeof node.value === "string";
|
|
}
|
|
|
|
const OBJECT = "object";
|
|
const STRING = "string";
|
|
const OPAQUE = "opaque";
|
|
|
|
/** Leaf kind of a property value (objects are followed, the rest are opaque). */
|
|
function entryKind(node) {
|
|
if (node.type === "ObjectExpression") return OBJECT;
|
|
if (isStringLiteral(node)) return STRING;
|
|
if (
|
|
node.type === "TemplateLiteral" &&
|
|
node.expressions.length === 0 &&
|
|
node.quasis.length === 1
|
|
) {
|
|
return STRING;
|
|
}
|
|
return OPAQUE;
|
|
}
|
|
|
|
function entryValue(node) {
|
|
if (isStringLiteral(node)) return node.value;
|
|
if (node.type === "TemplateLiteral") return node.quasis[0].value.cooked;
|
|
return null;
|
|
}
|
|
|
|
function propKey(prop) {
|
|
if (prop.key.type === "Identifier") return prop.key.name;
|
|
if (isStringLiteral(prop.key)) return prop.key.value;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Flatten an exported dict into `path -> { node, kind, value }`. Computed
|
|
* properties are skipped; intermediate objects are recorded too, so a
|
|
* missing whole section is reported once.
|
|
*/
|
|
function buildKeyMap(objectNode) {
|
|
const map = new Map();
|
|
const stack = [[objectNode, []]];
|
|
while (stack.length > 0) {
|
|
const [obj, prefix] = stack.pop();
|
|
for (const prop of obj.properties) {
|
|
if (prop.type !== "Property" || prop.computed) continue;
|
|
const key = propKey(prop);
|
|
if (key == null) continue;
|
|
const dot = [...prefix, key].join(".");
|
|
const kind = entryKind(prop.value);
|
|
map.set(dot, {
|
|
node: prop,
|
|
kind,
|
|
value: kind === STRING ? entryValue(prop.value) : null,
|
|
});
|
|
if (kind === OBJECT) stack.push([prop.value, [...prefix, key]]);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function placeholders(value) {
|
|
const set = new Set();
|
|
for (const m of String(value).matchAll(PLACEHOLDER)) set.add(m[1]);
|
|
return set;
|
|
}
|
|
|
|
function formatSet(set) {
|
|
return `{${[...set].sort().join(", ")}}`;
|
|
}
|
|
|
|
function matchesPattern(key, pattern) {
|
|
const keyParts = key.split(".");
|
|
const patternParts = pattern.split(".");
|
|
return (
|
|
keyParts.length >= patternParts.length &&
|
|
patternParts.every(
|
|
(part, index) => part === "*" || part === keyParts[index],
|
|
)
|
|
);
|
|
}
|
|
|
|
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
|
|
function* ancestorPaths(key) {
|
|
let i = key.lastIndexOf(".");
|
|
while (i !== -1) {
|
|
yield key.slice(0, i);
|
|
i = key.lastIndexOf(".", i - 1);
|
|
}
|
|
}
|
|
|
|
// Sibling dicts are read once per directory and cached for the whole lint run
|
|
// (mirrors how no-undefined-in-svelte caches the token dictionary).
|
|
const dirCache = new Map();
|
|
|
|
function loadDir(dir) {
|
|
if (!dirCache.has(dir)) dirCache.set(dir, readDir(dir));
|
|
return dirCache.get(dir);
|
|
}
|
|
|
|
function readDir(dir) {
|
|
const dictPath = path.join(dir, "dict.ts");
|
|
if (!fs.existsSync(dictPath)) return null;
|
|
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
|
|
const locales = findLocalesList(ast);
|
|
const baseLocale = findBaseLocale(ast);
|
|
if (!locales || locales.length === 0) return null;
|
|
|
|
const dicts = [];
|
|
for (const locale of locales) {
|
|
const file = path.join(dir, `${locale}.ts`);
|
|
if (!fs.existsSync(file)) {
|
|
// Declared in LOCALES but not yet translated; treated as an empty
|
|
// dict so every key it lacks reports `missing key ... in <locale>`.
|
|
dicts.push({ locale, map: new Map() });
|
|
continue;
|
|
}
|
|
const dictAst = parseTs(fs.readFileSync(file, "utf8"), file);
|
|
const dictExport = findDictExport(dictAst);
|
|
if (!dictExport) continue;
|
|
dicts.push({ locale, map: buildKeyMap(dictExport.object) });
|
|
}
|
|
|
|
// Union of every key across all locales (intermediate paths included), so a
|
|
// file can report exactly what it is missing.
|
|
const unionPaths = new Set();
|
|
// Non-empty string leaves per key: loccales contribute to the canonical
|
|
// placeholder set voting (empty values are already broken on their own).
|
|
const stringLeaves = new Map();
|
|
for (const d of dicts) {
|
|
for (const [key, entry] of d.map) {
|
|
unionPaths.add(key);
|
|
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
|
|
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
|
|
stringLeaves.get(key).push({
|
|
locale: d.locale,
|
|
set: placeholders(entry.value),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Canonical placeholder set per key: the set shared by the most locales;
|
|
// on a tie the base locale wins before LOCALES order.
|
|
const canonicalSets = new Map();
|
|
for (const [key, entries] of stringLeaves) {
|
|
const counts = new Map();
|
|
for (const { set } of entries) {
|
|
const sig = [...set].sort().join("\u0000");
|
|
counts.set(sig, (counts.get(sig) || 0) + 1);
|
|
}
|
|
let bestCount = 0;
|
|
let bestLocale = null;
|
|
for (const { locale, set } of entries) {
|
|
const sig = [...set].sort().join("\u0000");
|
|
const count = counts.get(sig);
|
|
if (
|
|
count > bestCount ||
|
|
(count === bestCount &&
|
|
locale === baseLocale &&
|
|
bestLocale !== baseLocale)
|
|
) {
|
|
bestCount = count;
|
|
bestLocale = locale;
|
|
canonicalSets.set(key, set);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { locales, baseLocale, dicts, unionPaths, canonicalSets };
|
|
}
|
|
|
|
export default {
|
|
meta: {
|
|
type: "suggestion",
|
|
docs: {
|
|
description:
|
|
"Check cross-locale consistency of the i18n dicts in lib/i18n (warn-only).",
|
|
category: "Best Practices",
|
|
},
|
|
schema: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
allowPaths: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
},
|
|
baseLocaleFallback: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
},
|
|
ignoreMissingPatterns: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
messages: {
|
|
missingKey: 'missing key "{{key}}" in {{locale}}',
|
|
emptyValue: 'empty value "{{key}}" in {{locale}}',
|
|
placeholderMismatch:
|
|
'placeholder mismatch for "{{key}}" in {{locale}}: {{active}} vs expected {{expected}}',
|
|
},
|
|
},
|
|
create(context) {
|
|
const options = context.options[0] || {};
|
|
const allowPaths = new Set(options.allowPaths || []);
|
|
const baseLocaleFallback = options.baseLocaleFallback || [];
|
|
const ignoreMissingPatterns = options.ignoreMissingPatterns || [];
|
|
|
|
const filename = path.resolve(
|
|
context.filename || context.physicalFilename || "",
|
|
);
|
|
const locale = path.basename(filename, path.extname(filename));
|
|
const dirInfo = loadDir(path.dirname(filename));
|
|
if (!dirInfo || !dirInfo.locales.includes(locale)) return {};
|
|
|
|
const dictExport = findDictExport(context.sourceCode.ast);
|
|
if (!dictExport) return {};
|
|
|
|
const own = buildKeyMap(dictExport.object);
|
|
const isMissingAllowed = (key) => {
|
|
if (allowPaths.has(key)) return true;
|
|
if (
|
|
ignoreMissingPatterns.some((pattern) => matchesPattern(key, pattern))
|
|
) {
|
|
return true;
|
|
}
|
|
return (
|
|
locale === dirInfo.baseLocale &&
|
|
baseLocaleFallback.some((pattern) => matchesPattern(key, pattern))
|
|
);
|
|
};
|
|
|
|
const missing = [...dirInfo.unionPaths].filter((key) => !own.has(key));
|
|
const requiredMissing = missing.filter((key) => {
|
|
if (isMissingAllowed(key)) return false;
|
|
const descendants = missing.filter((candidate) =>
|
|
candidate.startsWith(`${key}.`),
|
|
);
|
|
return (
|
|
descendants.length === 0 ||
|
|
descendants.some((candidate) => !isMissingAllowed(candidate))
|
|
);
|
|
});
|
|
const requiredSet = new Set(requiredMissing);
|
|
const topMissing = requiredMissing.filter(
|
|
(key) =>
|
|
![...ancestorPaths(key)].some((ancestor) => requiredSet.has(ancestor)),
|
|
);
|
|
for (const key of topMissing) {
|
|
context.report({
|
|
node: dictExport.id,
|
|
messageId: "missingKey",
|
|
data: { key, locale },
|
|
});
|
|
}
|
|
|
|
// Empty values and shared-key placeholder parity.
|
|
for (const [key, entry] of own) {
|
|
if (allowPaths.has(key) || entry.kind !== STRING) continue;
|
|
if ((entry.value ?? "").trim() === "") {
|
|
context.report({
|
|
node: entry.node,
|
|
messageId: "emptyValue",
|
|
data: { key, locale },
|
|
});
|
|
continue;
|
|
}
|
|
const canonical = dirInfo.canonicalSets.get(key);
|
|
if (!canonical) continue;
|
|
const active = placeholders(entry.value);
|
|
if (
|
|
active.size === canonical.size &&
|
|
[...active].every((p) => canonical.has(p))
|
|
) {
|
|
continue;
|
|
}
|
|
context.report({
|
|
node: entry.node,
|
|
messageId: "placeholderMismatch",
|
|
data: {
|
|
key,
|
|
locale,
|
|
active: formatSet(active),
|
|
expected: formatSet(canonical),
|
|
},
|
|
});
|
|
}
|
|
|
|
return {};
|
|
},
|
|
};
|