mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
chore: add new lint rule - check hardcoded text
This commit is contained in:
+3
-3
@@ -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%,
|
||||
|
||||
@@ -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` плюс
|
||||
строковые литералы пользовательских атрибутов; `<style>`/`<script>`
|
||||
пропускаются.
|
||||
- Исключения из плана реализованы списками, а не эвристиками: форматы/единицы/
|
||||
имена файлов/версии/размеры, ALL CAPS идентификаторы (registry source
|
||||
strings), `/kit` через scope, бренд через опцию `allowWords` (в конфиге, а не
|
||||
в коде правила).
|
||||
- Исправлены 2 бага: `ColorPairControl.svelte` показывал английские `From`/`To`.
|
||||
Ключи `ui.from`/`ui.to` уже существовали в обоих словарях — компонент их
|
||||
просто не использовал, поэтому новые ключи не понадобились.
|
||||
- 30 тестов в `__tests__/no-hardcoded-user-text.test.ts`; мутацией проверено,
|
||||
что правило не декоративное: с пустым `allowWords` бренд начинает репортиться.
|
||||
- Известные слепые зоны, зафиксированные как границы пилота: текст, собранный в
|
||||
скрипте и переданный выражением, и текст вне Svelte-шаблонов (например, в
|
||||
атрибутах, приходящих из registry). Расширение поверхности — отдельная работа,
|
||||
а не молчаливое расширение правила.
|
||||
|
||||
#### Q8.2. Дублирование HTML/Svelte-разметки
|
||||
|
||||
Сначала провести report-only аудит новых `components/**` и `routes/**`.
|
||||
Определить:
|
||||
@@ -490,20 +519,7 @@ file-result-инструментам.
|
||||
Только после оценки false positives выбирать между готовым detector и
|
||||
собственным AST-правилом. Не включать detector в blocking gate сразу.
|
||||
|
||||
#### Пользовательские тексты вне i18n
|
||||
|
||||
Сделать warn-only пилот с исключениями для:
|
||||
|
||||
- бренда и версии;
|
||||
- форматов и имён файлов;
|
||||
- `X`/`Y` и технических значений;
|
||||
- registry source strings;
|
||||
- `/kit`;
|
||||
- тестовых данных и внутренних сообщений.
|
||||
|
||||
Blanket-запрет всех string literals не использовать.
|
||||
|
||||
#### Согласованность комментариев
|
||||
#### Q8.3. Согласованность комментариев
|
||||
|
||||
Автоматический lint не делать. Это review/process policy: комментарий должен
|
||||
объяснять причину исключения, а `TODO`/`FIXME` — ссылаться на backlog или issue
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
`__fixtures__/` дописать их туда же.
|
||||
- design-tokens правила применяются к тем же scoped-путям
|
||||
(`src/lib/components/**`, `src/routes/**`).
|
||||
- `i18n/no-hardcoded-user-text` применяется к `src/lib/components/**` (`/kit`
|
||||
исключён как витрина с намеренно hardcoded-подписями) и настраивается опцией
|
||||
`allowWords` —brand не хардкодится в правиле.
|
||||
|
||||
## Правила плагина design-tokens
|
||||
|
||||
@@ -60,7 +63,9 @@
|
||||
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
||||
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
||||
|
||||
## Правило плагина i18n (`i18n/dict-consistency`)
|
||||
## Плагин i18n (`i18n/`)
|
||||
|
||||
### `i18n/dict-consistency`
|
||||
|
||||
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
||||
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
||||
@@ -97,6 +102,28 @@
|
||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||
диске.
|
||||
|
||||
### `i18n/no-hardcoded-user-text`
|
||||
|
||||
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
|
||||
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
|
||||
report-only (`warn`), baseline — 0 срабатываний.
|
||||
|
||||
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
|
||||
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
|
||||
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
|
||||
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
|
||||
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
|
||||
скобках (`{expr}`) тоже — они считаются в другом месте.
|
||||
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
|
||||
имена файлов и версии (`result.png`, `v0.1.0`), размеры (`512×512`),
|
||||
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
|
||||
`/kit` — витрина компонентов с намеренно латинскими подписями.
|
||||
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
|
||||
продукта передаётся конфигом, а не зашито в правило.
|
||||
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
|
||||
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
|
||||
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
|
||||
|
||||
## Токены-префиксы (целевой словарь дизайна)
|
||||
|
||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Tests for the i18n/no-hardcoded-user-text rule
|
||||
// (web/eslint-plugins/i18n/no-hardcoded-user-text.js).
|
||||
//
|
||||
// The rule is a pure AST check over the Svelte template, so it runs through the
|
||||
// standard RuleTester with svelte-eslint-parser + the TS sub-parser. The
|
||||
// exclusion list (brand, formats, units, registry source strings) is the whole
|
||||
// point of the pilot, so every exclusion has a case here.
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import rule, { hasUserWords } from "../i18n/no-hardcoded-user-text.js";
|
||||
import { asRuleModule } from "./helpers.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: { parser: tseslint.parser },
|
||||
},
|
||||
});
|
||||
|
||||
const OPTIONS = [{ allowWords: ["easy-png-tools"] }];
|
||||
|
||||
ruleTester.run("no-hardcoded-user-text", asRuleModule(rule), {
|
||||
valid: [
|
||||
// Translated text comes from t(), never from a literal.
|
||||
{ code: '<p>{t("ui.reset")}</p>', filename: "Component.svelte" },
|
||||
{
|
||||
code: '<Button label={t("actions.download")} />',
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
// Formats, units and file names are not copy.
|
||||
{ code: "<p>PNG</p>", filename: "Component.svelte" },
|
||||
{ code: "<p>result.png</p>", filename: "Component.svelte" },
|
||||
{ code: "<p>512×512</p>", filename: "Component.svelte" },
|
||||
{ code: '<span class="u">px</span>', filename: "Component.svelte" },
|
||||
{ code: "<p>1.0 s</p>", filename: "Component.svelte" },
|
||||
{ code: "<p>v0.1.0</p>", filename: "Component.svelte" },
|
||||
// Registry source strings and other shouted identifiers.
|
||||
{ code: "<p>BACKGROUND</p>", filename: "Component.svelte" },
|
||||
{ code: '<StepCard type="TRANSFORM" />', filename: "Component.svelte" },
|
||||
// Technical attributes are out of scope.
|
||||
{
|
||||
code: '<Button variant="outline" tone="danger" size="lg" />',
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
{
|
||||
code: '<a href="/tools/resize-png">{t("nav.home")}</a>',
|
||||
filename: "x.svelte",
|
||||
},
|
||||
// Values written as expressions are computed elsewhere.
|
||||
{
|
||||
code: "<p>{value}</p>",
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
{
|
||||
code: "<EmptyState title={copy} description={hint} />",
|
||||
filename: "Component.svelte",
|
||||
},
|
||||
// Brand configured as an allowed word.
|
||||
{
|
||||
code: "<span>easy-png-tools</span>",
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
{
|
||||
code: "<footer><span>easy-png-tools</span><span>v0.1.0</span></footer>",
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
// <style> and <script> bodies are code, not copy.
|
||||
{
|
||||
code: '<p>{t("ui.reset")}</p>\n<style>.a { color: red; }</style>',
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
{
|
||||
code: '<script>const hint = "internal message";</script>\n<p>{t("ui.reset")}</p>',
|
||||
filename: "Component.svelte",
|
||||
options: OPTIONS,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "<p>Visible text</p>",
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<span class="axis-label">From</span>',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<Button label="Download result" />',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<Button label="outline" variant="outline" />',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
code: '<ImageCard alt="Preview of the processed image" />',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
// Mixed content: the text part carries copy, the unit part does not.
|
||||
code: "<p>Result {value} px</p>",
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
// Navigation copy is user text too.
|
||||
code: '<a href="/tools/resize-png">Home</a>',
|
||||
filename: "Component.svelte",
|
||||
errors: [{ messageId: "hardcodedText" }],
|
||||
},
|
||||
{
|
||||
// Nested elements are walked too.
|
||||
code: "<div><span><b>Delete file</b></span></div>",
|
||||
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: "<span>easy-png-tools</span>",
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
@@ -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 ?? []) {
|
||||
// <style> and <script> content is code, not copy. Their raw text
|
||||
// arrives as a text child, so the elements are skipped wholesale.
|
||||
if (node.type === "SvelteStyleElement") continue;
|
||||
if (node.type === "SvelteScriptElement") continue;
|
||||
if (node.type === "SvelteText") {
|
||||
if (hasUserWords(node.value, allowWords)) {
|
||||
reportLiteral(context, node, normalize(node.value), "text");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (node.type === "SvelteElement") {
|
||||
for (const attr of node.startTag?.attributes ?? []) {
|
||||
const name = attr.key?.name;
|
||||
if (typeof name !== "string") continue;
|
||||
if (!USER_TEXT_ATTRS.has(name)) continue;
|
||||
const value = literalValue(attr);
|
||||
if (value === null) continue;
|
||||
if (hasUserWords(value, allowWords)) {
|
||||
reportLiteral(
|
||||
context,
|
||||
attr,
|
||||
normalize(value),
|
||||
`${name} attribute`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
visitTemplate(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"Program:exit"() {
|
||||
visitTemplate(program?.body);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
+20
-2
@@ -83,6 +83,7 @@ export default tseslint.config(
|
||||
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
||||
"eslint-plugins/__tests__/helpers.ts",
|
||||
"eslint-plugins/__tests__/dict-consistency.test.ts",
|
||||
"eslint-plugins/__tests__/no-hardcoded-user-text.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",
|
||||
@@ -165,8 +166,9 @@ export default tseslint.config(
|
||||
"conventions/no-string-union-alias": "error",
|
||||
},
|
||||
},
|
||||
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
||||
// Правило сообщает warning, а `lint:all` превращает новые warnings в
|
||||
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency) и запрет
|
||||
// захардкоженного пользовательского текста (i18n/no-hardcoded-user-text).
|
||||
// Правила сообщают warning, а `lint:all` превращает новые warnings в
|
||||
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
|
||||
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
|
||||
{
|
||||
@@ -184,6 +186,22 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// /kit — витрина компонентов с намеренно захардкоженными подписями.
|
||||
files: newSvelteFiles.filter((pattern) => !pattern.includes("routes")),
|
||||
plugins: {
|
||||
i18n: i18nPlugin,
|
||||
},
|
||||
rules: {
|
||||
"i18n/no-hardcoded-user-text": [
|
||||
"warn",
|
||||
{
|
||||
// Название продукта — бренд, а не переводимый текст.
|
||||
allowWords: ["easy-png-tools"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: toolingFiles,
|
||||
languageOptions: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/t";
|
||||
import type {
|
||||
ColorPair,
|
||||
ColorPairSpec,
|
||||
@@ -37,7 +38,7 @@
|
||||
value={current.from}
|
||||
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<span class="axis-label">From</span>
|
||||
<span class="axis-label">{t("ui.from")}</span>
|
||||
</label>
|
||||
<label class="pair-axis">
|
||||
<span class="swatch" style="background:{current.to}"></span>
|
||||
@@ -46,7 +47,7 @@
|
||||
value={current.to}
|
||||
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<span class="axis-label">To</span>
|
||||
<span class="axis-label">{t("ui.to")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user