diff --git a/docs/README.md b/docs/README.md
index 90dd503..99c69e6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -57,9 +57,9 @@
- production registry содержит 122 инструмента;
- `pnpm --dir web check` проходит без ошибок и предупреждений;
-- `pnpm --dir web test` — 45 test files / 497 tests, включая тесты token audit и
- PostCSS HCT-плагина;
-- `pnpm --dir web test:rules` — 4 test files / 74 tests;
+- `pnpm --dir web test` — 46 test files / 534 tests, включая тесты token audit,
+ PostCSS HCT-плагина и правил линтинга;
+- `pnpm --dir web test:rules` — 5 test files / 104 tests;
- `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens
от token audit;
- coverage `src/lib` — statements 91.77%, branches 83.3%, functions 74.31%,
diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md
index cd20a37..ca549ea 100644
--- a/docs/plan-codebase-quality.md
+++ b/docs/plan-codebase-quality.md
@@ -476,9 +476,38 @@ file-result-инструментам.
### Q8. Новые автоматические правила
-**Оценка:** L. **Зависимости:** Q4, Q7.
+**Оценка:** L. **Зависимости:** Q4, Q7. **Разбивка:** три атомарных шага, каждый
+заканчивается либо работающим warn-правилом с нулевым baseline, либо измерением
+с записью вывода. Механически важно: `lint-all` запускает ESLint с
+`--max-warnings=0`, поэтому warn-правило обязано приземлиться без срабатываний
+на текущем коде.
-#### Дублирование HTML/Svelte-разметки
+#### Q8.1. Пользовательские тексты вне i18n
+
+**Статус:** выполнен. Пилот `i18n/no-hardcoded-user-text` (warn) с нулевым
+baseline; 2 реальных нарушения исправлены.
+
+- Измерение до правила: 42 строковых атрибута пользовательского текста, все — в
+ `/kit`; вне кита текстовые узлы дали 2 находки. Итог после исключений: 2
+ настоящих бага и 2 бренда, которые план требовал исключить.
+- `web/eslint-plugins/i18n/no-hardcoded-user-text.js`: узлы `SvelteText` плюс
+ строковые литералы пользовательских атрибутов; `',
+ filename: "Component.svelte",
+ options: OPTIONS,
+ },
+ {
+ code: '\n
{t("ui.reset")}
',
+ filename: "Component.svelte",
+ options: OPTIONS,
+ },
+ ],
+ invalid: [
+ {
+ code: "Visible text
",
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ code: 'From',
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ code: '',
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ code: '',
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ code: '',
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ // Mixed content: the text part carries copy, the unit part does not.
+ code: "Result {value} px
",
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ // Navigation copy is user text too.
+ code: 'Home',
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ // Nested elements are walked too.
+ code: "Delete file
",
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ {
+ // Without the option the brand word is reported: the exclusion is
+ // configuration, not a hardcoded constant inside the rule.
+ code: "easy-png-tools",
+ filename: "Component.svelte",
+ errors: [{ messageId: "hardcodedText" }],
+ },
+ ],
+});
+
+describe("hasUserWords", () => {
+ it("accepts technical fragments", () => {
+ for (const value of [
+ "",
+ " ",
+ "px",
+ "512×512",
+ "result.png",
+ "1.0 s",
+ "v0.1.0",
+ "× ·",
+ "BACKGROUND",
+ "·",
+ ]) {
+ expect(hasUserWords(value), value).toBe(false);
+ }
+ });
+
+ it("accepts configured brand words", () => {
+ expect(hasUserWords("easy-png-tools", ["easy-png-tools"])).toBe(false);
+ expect(hasUserWords("easy-png-tools")).toBe(true);
+ });
+
+ it("accepts formats and units next to real copy", () => {
+ expect(hasUserWords("PNG 512×512")).toBe(false);
+ expect(hasUserWords("Result PNG")).toBe(true);
+ });
+});
diff --git a/web/eslint-plugins/i18n/index.js b/web/eslint-plugins/i18n/index.js
index 49f8868..ecc31f9 100644
--- a/web/eslint-plugins/i18n/index.js
+++ b/web/eslint-plugins/i18n/index.js
@@ -8,6 +8,7 @@
* new locales are picked up automatically.
*/
import dictConsistency from "./dict-consistency.js";
+import noHardcodedUserText from "./no-hardcoded-user-text.js";
export default {
meta: {
@@ -16,5 +17,6 @@ export default {
},
rules: {
"dict-consistency": dictConsistency,
+ "no-hardcoded-user-text": noHardcodedUserText,
},
};
diff --git a/web/eslint-plugins/i18n/lists.js b/web/eslint-plugins/i18n/lists.js
new file mode 100644
index 0000000..50fa8ca
--- /dev/null
+++ b/web/eslint-plugins/i18n/lists.js
@@ -0,0 +1,19 @@
+// Lists shared by the i18n rules.
+
+// Attributes whose string value is shown to a user, so a literal there is
+// hardcoded copy. Technical attributes (variant, tone, type, icon, size, role,
+// href, id, class, name) are deliberately absent.
+export const USER_TEXT_ATTRS = new Set([
+ "alt",
+ "aria-label",
+ "description",
+ "emptyText",
+ "error",
+ "eyebrow",
+ "hint",
+ "label",
+ "placeholder",
+ "suffix",
+ "title",
+ "unit",
+]);
diff --git a/web/eslint-plugins/i18n/no-hardcoded-user-text.js b/web/eslint-plugins/i18n/no-hardcoded-user-text.js
new file mode 100644
index 0000000..bb31921
--- /dev/null
+++ b/web/eslint-plugins/i18n/no-hardcoded-user-text.js
@@ -0,0 +1,180 @@
+// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
+//
+// Every user-visible string must come from the dictionaries via t(); a literal in
+// the template silently ships English to the RU locale. Warn-only by design
+// (report-only pilot), so the baseline stays green while the rule is calibrated.
+//
+// What is NOT reported (the pilot's exclusion list, from the plan):
+// - brand and version strings ("easy-png-tools", "v0.1.0");
+// - formats, file names and units ("PNG", "result.png", "512×512", "px", "%");
+// - X/Y axis markers and other one-letter technical markers;
+// - registry source strings (ALL CAPS identifiers such as "BACKGROUND");
+// - /kit, which is a component gallery with deliberately hardcoded labels.
+//
+// Detection surface: SvelteText nodes (including the text parts of mixed content)
+// and string-literal values of user-facing attributes. Values written as
+// expressions ({...}) are not inspected — the value is computed elsewhere.
+
+import { USER_TEXT_ATTRS } from "./lists.js";
+
+// Formats, units and axis markers that look like words but are not copy.
+const TECH_WORDS = new Set([
+ "png",
+ "webp",
+ "jpg",
+ "jpeg",
+ "bmp",
+ "gif",
+ "svg",
+ "zip",
+ "hct",
+ "oklch",
+ "rgb",
+ "rgba",
+ "px",
+ "em",
+ "rem",
+ "ms",
+ "s",
+ "x",
+ "y",
+ "kb",
+ "mb",
+ "gb",
+ "dpi",
+ "bit",
+ "bits",
+ "i18n",
+ "l10n",
+ "url",
+ "uuid",
+ "v1",
+ "v2",
+]);
+
+// Tokens made only of digits, separators and technical characters.
+const TECHNICAL_ONLY_RE = /^[\d\s.,:;·×x/%()[\]{}+\-–—*=<>!?@'"`|~^$]*$/i;
+
+// A file name, an extension, a version, a dimension pair, a size with a unit.
+const TECH_TOKEN_RE =
+ /(\.(png|webp|jpg|jpeg|bmp|gif|svg|zip|json|ts|svelte|css|md)\b)|(\bv?\d+(\.\d+)+\b)|(\d+\s*[×x]\s*\d+)|(\d+(\.\d+)?\s*(px|em|rem|%|px|s\b))/i;
+
+/** Collapse whitespace so mixed content like "Result px" can be judged. */
+function normalize(value) {
+ return value.replace(/\s+/g, " ").trim();
+}
+
+/**
+ * True when the fragment carries a word that a user is meant to read.
+ */
+export function hasUserWords(raw, allowWords = []) {
+ const text = normalize(raw);
+ if (text === "") return false;
+ if (TECHNICAL_ONLY_RE.test(text)) return false;
+
+ // Registry source strings and other shouted identifiers are not copy.
+ if (/^[A-Z0-9_ -]+$/.test(text) && /[A-Z]/.test(text)) return false;
+
+ const allowed = new Set(allowWords.map((word) => word.toLowerCase()));
+ const words = text.split(/[\s·×]+/).filter(Boolean);
+ const candidates = [];
+ for (const word of words) {
+ // "512", "64", "0.3" and friends carry no letters at all.
+ if (!/[A-Za-z]/.test(word)) continue;
+ if (TECH_TOKEN_RE.test(word)) continue;
+ if (TECH_WORDS.has(word.toLowerCase())) continue;
+ // Brand and product names are configured per repository, not hardcoded here.
+ if (allowed.has(word.toLowerCase())) continue;
+ candidates.push(word);
+ }
+ return candidates.length > 0;
+}
+
+function reportLiteral(context, node, value, kind) {
+ context.report({
+ node,
+ messageId: "hardcodedText",
+ data: { value, kind },
+ });
+}
+
+// svelte-eslint-parser exposes an attribute value as a LIST of nodes, so a
+// quoted literal is `value[0]`. Anything else ({expr}, spread, no value) means
+// the text is computed elsewhere and is out of scope.
+function literalValue(attr) {
+ const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
+ const first = parts.find((part) => part && typeof part === "object");
+ if (!first || first.type !== "SvelteLiteral") return null;
+ return typeof first.value === "string" ? first.value : null;
+}
+
+export default {
+ meta: {
+ type: "suggestion",
+ docs: {
+ description:
+ "Warn on hardcoded user-visible text in Svelte templates; use t() from the dictionaries.",
+ category: "i18n",
+ recommended: false,
+ },
+ messages: {
+ hardcodedText:
+ "Hardcoded user text ({{kind}}): '{{value}}'. Use t() with a key in both locale dictionaries.",
+ },
+ schema: [
+ {
+ type: "object",
+ properties: {
+ allowWords: {
+ type: "array",
+ items: { type: "string" },
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ },
+ create(context) {
+ const { allowWords = [] } = context.options[0] ?? {};
+ const program = context.sourceCode?.ast ?? context.parserServices?.ast;
+
+ function visitTemplate(nodes) {
+ for (const node of nodes ?? []) {
+ //