mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
chore: add i18n dict consistency lint rule
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## lint и прочие правила
|
## lint и прочие правила
|
||||||
|
|
||||||
- [ ] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
правило `i18n/dict-consistency`, детали в §Фаза 8
|
||||||
`docs/plan-preview-i18n.md`
|
`docs/plan-preview-i18n.md`
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
||||||
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
||||||
- `isolation/` — правило изоляции old/new UI.
|
- `isolation/` — правило изоляции old/new UI.
|
||||||
|
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
||||||
|
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
||||||
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
||||||
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
||||||
токенов) и файлы для изоляционных тестов.
|
токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`.
|
||||||
- `web/scripts/`:
|
- `web/scripts/`:
|
||||||
- `lint-all.mjs` — оркестратор `lint:all`.
|
- `lint-all.mjs` — оркестратор `lint:all`.
|
||||||
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
||||||
@@ -62,6 +64,35 @@
|
|||||||
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
||||||
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
||||||
|
|
||||||
|
## Правило плагина i18n (`i18n/dict-consistency`)
|
||||||
|
|
||||||
|
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md`
|
||||||
|
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только
|
||||||
|
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый
|
||||||
|
словарь подхватывается без правки правила.
|
||||||
|
|
||||||
|
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
||||||
|
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
||||||
|
расхождения:
|
||||||
|
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
|
||||||
|
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
|
||||||
|
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
|
||||||
|
пропавшая целая секция репортится один раз (не по каждому потомку);
|
||||||
|
- **пустые значения**: `""` и строка из одних пробелов;
|
||||||
|
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
||||||
|
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
|
||||||
|
— ловит потерянную при переводе переменную ровно один раз, даже когда
|
||||||
|
отклоняется одна-единственная локаль.
|
||||||
|
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
||||||
|
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
||||||
|
(синтакс, без типов).
|
||||||
|
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
||||||
|
(осознанные отклонения до достижения zero-warn).
|
||||||
|
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||||
|
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||||
|
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||||
|
диске.
|
||||||
|
|
||||||
## Токены-префиксы (целевой словарь дизайна)
|
## Токены-префиксы (целевой словарь дизайна)
|
||||||
|
|
||||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Dict } from "./dict";
|
||||||
|
|
||||||
|
export const de: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: "Arbeitsbereich",
|
||||||
|
catalog: "Katalog",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
hero: "Hallo {name}!",
|
||||||
|
restoreLast: "{title} wiederherstellen",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sourceRequired: "Quelle erforderlich",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
addBorder: {
|
||||||
|
title: "Rahmen hinzufügen",
|
||||||
|
description: "Zeichnet einen {kind}-Rahmen.",
|
||||||
|
results: {
|
||||||
|
done: "Rahmen {what}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const LOCALES = ["en", "ru", "de"] as const;
|
||||||
|
|
||||||
|
export const BASE_LOCALE: "en" = "en";
|
||||||
|
|
||||||
|
export type Dict = {
|
||||||
|
header: Record<string, string>;
|
||||||
|
home: Record<string, string>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
tools: Record<string, ToolStrings>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolStrings = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
results?: Record<string, string>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Dict } from "./dict";
|
||||||
|
|
||||||
|
export const en: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: "Workspace",
|
||||||
|
catalog: "Catalog",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
hero: "Hello {name}!",
|
||||||
|
restoreLast: "Restore {title}",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sourceRequired: "Source image required",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
addBorder: {
|
||||||
|
title: "Add border",
|
||||||
|
description: "Draws a {kind} frame.",
|
||||||
|
results: {
|
||||||
|
done: "Border {what}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Dict } from "./dict";
|
||||||
|
|
||||||
|
export const ru: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: "Рабочая область",
|
||||||
|
catalog: "Каталог",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
hero: "Привет, {name}!",
|
||||||
|
restoreLast: "Вернуть {title}",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sourceRequired: "Нужен исходник",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
addBorder: {
|
||||||
|
title: "Добавить рамку",
|
||||||
|
description: "Рисует рамку {kind}.",
|
||||||
|
results: {
|
||||||
|
done: "Рамка {what}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Tests for i18n/dict-consistency (web/eslint-plugins/i18n/dict-consistency.js).
|
||||||
|
//
|
||||||
|
// The rule reads its own dict dir from disk (LOCALES in dict.ts + sibling
|
||||||
|
// locale files), so it runs through the Linter API with cwd pinned to the
|
||||||
|
// fixture tree (see __tests__/helpers.ts). The committed fixtures at
|
||||||
|
// __fixtures__/src/lib/i18n/ are mutually consistent (zero-warn) — tests feed
|
||||||
|
// MODIFIED variants of en.ts / ru.ts as the linted code so each check is
|
||||||
|
// exercised against the on-disk references.
|
||||||
|
//
|
||||||
|
// Reporting contract: each file reports only its OWN gaps. A key missing in a
|
||||||
|
// file produces exactly ONE `missingKey` warning, emitted on the incomplete
|
||||||
|
// file itself (the union of all keys comes from the on-disk dicts), regardless
|
||||||
|
// of how many other locales have the key. Placeholder parity is compared
|
||||||
|
// against the canonical set — the one shared by the most locales (ties by
|
||||||
|
// LOCALES order) — so a deviation is reported exactly once.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import dictConsistency from "../i18n/dict-consistency.js";
|
||||||
|
import {
|
||||||
|
asRuleModule,
|
||||||
|
FIXTURES_SRC,
|
||||||
|
verifyInFixtures,
|
||||||
|
type FlatConfig,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
const I18N_DIR = path.join(FIXTURES_SRC, "lib", "i18n");
|
||||||
|
|
||||||
|
const fixture = (name: string) =>
|
||||||
|
fs.readFileSync(path.join(I18N_DIR, name), "utf8");
|
||||||
|
|
||||||
|
function lint(code: string, relFile: string, options?: unknown) {
|
||||||
|
const rule = options ? ["warn", options] : "warn";
|
||||||
|
const config: FlatConfig = [
|
||||||
|
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
plugins: {
|
||||||
|
i18n: {
|
||||||
|
rules: { "dict-consistency": asRuleModule(dictConsistency) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: { "i18n/dict-consistency": rule as "warn" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return verifyInFixtures(config, code, relFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageIds(messages: { messageId?: string }[]) {
|
||||||
|
return messages.map((m) => m.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("i18n/dict-consistency", () => {
|
||||||
|
it("stays clean on the consistent fixture dicts", () => {
|
||||||
|
expect(messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts"))).toEqual([]);
|
||||||
|
expect(messageIds(lint(fixture("ru.ts"), "lib/i18n/ru.ts"))).toEqual([]);
|
||||||
|
expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a leaf key missing in the current file", () => {
|
||||||
|
const enMissing = fixture("en.ts").replace(
|
||||||
|
'\t\trestoreLast: "Restore {title}",',
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const messages = lint(enMissing, "lib/i18n/en.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||||
|
expect(messages[0].message).toContain('"home.restoreLast"');
|
||||||
|
expect(messages[0].message).toContain(" in en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing whole section once, not per child key", () => {
|
||||||
|
const enNoHeader = fixture("en.ts").replace(/\theader: \{[\s\S]*?\},/, "");
|
||||||
|
const messages = lint(enNoHeader, "lib/i18n/en.ts");
|
||||||
|
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||||
|
expect(messages[0].message).toContain('"header"');
|
||||||
|
expect(messages[0].message).not.toContain("header.workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits ONE warning per gap even when several locales own the key", () => {
|
||||||
|
const enMissing = fixture("en.ts").replace(
|
||||||
|
'\t\tsourceRequired: "Source image required",',
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const messages = lint(enMissing, "lib/i18n/en.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||||
|
expect(messages[0].message).toContain('"errors.sourceRequired"');
|
||||||
|
expect(messages[0].message).toContain(" in en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags empty and whitespace-only values", () => {
|
||||||
|
const enEmpty = fixture("en.ts")
|
||||||
|
.replace('workspace: "Workspace",', 'workspace: "",')
|
||||||
|
.replace('catalog: "Catalog",', 'catalog: " ",');
|
||||||
|
const messages = lint(enEmpty, "lib/i18n/en.ts");
|
||||||
|
expect(messageIds(messages)).toEqual(["emptyValue", "emptyValue"]);
|
||||||
|
expect(messages[0].message).toContain('"header.workspace"');
|
||||||
|
expect(messages[0].message).toContain(" in en");
|
||||||
|
expect(messages[1].message).toContain('"header.catalog"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a placeholder lost in translation (vs the canonical set)", () => {
|
||||||
|
const ruLostPlaceholder = fixture("ru.ts").replace(
|
||||||
|
'hero: "Привет, {name}!",',
|
||||||
|
'hero: "Привет!",',
|
||||||
|
);
|
||||||
|
const messages = lint(ruLostPlaceholder, "lib/i18n/ru.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
|
||||||
|
expect(messages[0].message).toContain('"home.hero"');
|
||||||
|
expect(messages[0].message).toContain("{} vs expected {name}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags an extra placeholder introduced in one locale", () => {
|
||||||
|
const enExtraPlaceholder = fixture("en.ts").replace(
|
||||||
|
'description: "Draws a {kind} frame.",',
|
||||||
|
'description: "Draws a {kind} frame by {author}.",',
|
||||||
|
);
|
||||||
|
const messages = lint(enExtraPlaceholder, "lib/i18n/en.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
|
||||||
|
expect(messages[0].message).toContain('"tools.addBorder.description"');
|
||||||
|
expect(messages[0].message).toContain("{author, kind} vs expected {kind}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours allowPaths for conscious deviations", () => {
|
||||||
|
const enMissing = fixture("en.ts").replace(
|
||||||
|
'\t\tsourceRequired: "Source image required",',
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const messages = lint(enMissing, "lib/i18n/en.ts", {
|
||||||
|
allowPaths: ["errors.sourceRequired"],
|
||||||
|
});
|
||||||
|
expect(messageIds(messages)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores files that are not locale dicts", () => {
|
||||||
|
const notALocale = `
|
||||||
|
export const helper = { a: "b" };
|
||||||
|
`;
|
||||||
|
expect(messageIds(lint(notALocale, "lib/i18n/t.ts"))).toEqual([]);
|
||||||
|
expect(messageIds(lint(notALocale, "lib/i18n/misc.ts"))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* Cross-locale dictionary linter (i18n/dict-consistency).
|
||||||
|
*
|
||||||
|
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just
|
||||||
|
* against the base locale). The set of locales comes from `LOCALES` in the
|
||||||
|
* sibling `dict.ts`, so a new locale is picked up automatically — no rule
|
||||||
|
* changes needed. The rule only ever warns (a missing translation must not
|
||||||
|
* break the build) — see docs/plan-preview-i18n.md §Фаза 8.
|
||||||
|
*
|
||||||
|
* The diagnostic lives where the fix lives — each file reports its OWN gaps:
|
||||||
|
* - missing keys: the union of all keys is collected from the on-disk dicts;
|
||||||
|
* whatever the current file lacks is reported here (a missing whole
|
||||||
|
* section is reported once, not per descendant key);
|
||||||
|
* - empty values: `""` or whitespace-only values;
|
||||||
|
* - placeholders: for a shared key the `{name}` set is compared against the
|
||||||
|
* canonical set (the one shared by most locales, ties broken by LOCALES
|
||||||
|
* order) — catches a variable lost in translation exactly once, even when
|
||||||
|
* only one locale deviates.
|
||||||
|
*
|
||||||
|
* The rule self-filters: files whose basename is not one of LOCALES are
|
||||||
|
* ignored, so it can be attached to the whole `lib/i18n/` directory.
|
||||||
|
*
|
||||||
|
* Options (object, all optional):
|
||||||
|
* - allowPaths: dot-path keys excluded from every check (conscious
|
||||||
|
* deviations until the dict reaches zero-warn, see Фаза 8 §8.3).
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 object into `path -> { node, kind, value }`.
|
||||||
|
* Computed properties are skipped; intermediate objects are recorded too, so a
|
||||||
|
* missing whole section is reported once (children of a missing path are
|
||||||
|
* skipped when reporting).
|
||||||
|
*/
|
||||||
|
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(", ")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
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(placeholders(entry.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical placeholder set per key: the set shared by the most locales;
|
||||||
|
// on a tie the first one encountered (LOCALES order) wins.
|
||||||
|
const canonicalSets = new Map();
|
||||||
|
for (const [key, sets] of stringLeaves) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const set of sets) {
|
||||||
|
const sig = [...set].sort().join("\u0000");
|
||||||
|
counts.set(sig, (counts.get(sig) || 0) + 1);
|
||||||
|
}
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const set of sets) {
|
||||||
|
const sig = [...set].sort().join("\u0000");
|
||||||
|
if (counts.get(sig) > bestCount) {
|
||||||
|
bestCount = counts.get(sig);
|
||||||
|
canonicalSets.set(key, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locales, 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
// Missing keys: everything the union has but the current file lacks.
|
||||||
|
// Top-level gaps only — a missing ancestor already covers its subtree.
|
||||||
|
const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k));
|
||||||
|
const missingSet = new Set(missing);
|
||||||
|
const topMissing = missing.filter(
|
||||||
|
(k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)),
|
||||||
|
);
|
||||||
|
for (const key of topMissing) {
|
||||||
|
if (allowPaths.has(key)) continue;
|
||||||
|
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 {};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Local ESLint plugin "i18n".
|
||||||
|
*
|
||||||
|
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in
|
||||||
|
* `lib/i18n/` is compared against all the others — key parity, empty values,
|
||||||
|
* `{placeholder}` parity — warn-only by design (a missing translation must not
|
||||||
|
* break the build). The set of locales comes from `LOCALES` in `dict.ts`, so
|
||||||
|
* new locales are picked up automatically.
|
||||||
|
*/
|
||||||
|
import dictConsistency from "./dict-consistency.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
name: "i18n",
|
||||||
|
version: "0.1.0",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"dict-consistency": dictConsistency,
|
||||||
|
},
|
||||||
|
};
|
||||||
+20
-1
@@ -7,6 +7,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import designTokens from "./eslint-plugins/index.js";
|
import designTokens from "./eslint-plugins/index.js";
|
||||||
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
||||||
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
||||||
|
import i18nPlugin from "./eslint-plugins/i18n/index.js";
|
||||||
|
|
||||||
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
||||||
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
||||||
@@ -82,6 +83,11 @@ export default tseslint.config(
|
|||||||
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
||||||
"eslint-plugins/__tests__/helpers.ts",
|
"eslint-plugins/__tests__/helpers.ts",
|
||||||
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
|
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
|
||||||
|
"eslint-plugins/__tests__/dict-consistency.test.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/de.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
|
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
||||||
@@ -92,7 +98,7 @@ export default tseslint.config(
|
|||||||
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
|
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
|
||||||
],
|
],
|
||||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32,
|
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
||||||
},
|
},
|
||||||
extraFileExtensions: [".svelte"],
|
extraFileExtensions: [".svelte"],
|
||||||
},
|
},
|
||||||
@@ -168,6 +174,19 @@ export default tseslint.config(
|
|||||||
"conventions/no-string-union-alias": "error",
|
"conventions/no-string-union-alias": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
||||||
|
// warn-only: пропущенный перевод не должен валить сборку. Список локалей
|
||||||
|
// берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются
|
||||||
|
// автоматически; правило игнорирует не-локали в этой папке само.
|
||||||
|
{
|
||||||
|
files: ["**/src/lib/i18n/*.ts"],
|
||||||
|
plugins: {
|
||||||
|
i18n: i18nPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"i18n/dict-consistency": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
// Полные recommended-наборы — только на новый код.
|
// Полные recommended-наборы — только на новый код.
|
||||||
...[
|
...[
|
||||||
...jsRecommended.map((cfg) => ({
|
...jsRecommended.map((cfg) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user