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 инструмента;
|
- production registry содержит 122 инструмента;
|
||||||
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
||||||
- `pnpm --dir web test` — 45 test files / 497 tests, включая тесты token audit и
|
- `pnpm --dir web test` — 46 test files / 534 tests, включая тесты token audit,
|
||||||
PostCSS HCT-плагина;
|
PostCSS HCT-плагина и правил линтинга;
|
||||||
- `pnpm --dir web test:rules` — 4 test files / 74 tests;
|
- `pnpm --dir web test:rules` — 5 test files / 104 tests;
|
||||||
- `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens
|
- `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens
|
||||||
от token audit;
|
от token audit;
|
||||||
- coverage `src/lib` — statements 91.77%, branches 83.3%, functions 74.31%,
|
- coverage `src/lib` — statements 91.77%, branches 83.3%, functions 74.31%,
|
||||||
|
|||||||
@@ -476,9 +476,38 @@ file-result-инструментам.
|
|||||||
|
|
||||||
### Q8. Новые автоматические правила
|
### 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/**`.
|
Сначала провести report-only аудит новых `components/**` и `routes/**`.
|
||||||
Определить:
|
Определить:
|
||||||
@@ -490,20 +519,7 @@ file-result-инструментам.
|
|||||||
Только после оценки false positives выбирать между готовым detector и
|
Только после оценки false positives выбирать между готовым detector и
|
||||||
собственным AST-правилом. Не включать detector в blocking gate сразу.
|
собственным AST-правилом. Не включать detector в blocking gate сразу.
|
||||||
|
|
||||||
#### Пользовательские тексты вне i18n
|
#### Q8.3. Согласованность комментариев
|
||||||
|
|
||||||
Сделать warn-only пилот с исключениями для:
|
|
||||||
|
|
||||||
- бренда и версии;
|
|
||||||
- форматов и имён файлов;
|
|
||||||
- `X`/`Y` и технических значений;
|
|
||||||
- registry source strings;
|
|
||||||
- `/kit`;
|
|
||||||
- тестовых данных и внутренних сообщений.
|
|
||||||
|
|
||||||
Blanket-запрет всех string literals не использовать.
|
|
||||||
|
|
||||||
#### Согласованность комментариев
|
|
||||||
|
|
||||||
Автоматический lint не делать. Это review/process policy: комментарий должен
|
Автоматический lint не делать. Это review/process policy: комментарий должен
|
||||||
объяснять причину исключения, а `TODO`/`FIXME` — ссылаться на backlog или issue
|
объяснять причину исключения, а `TODO`/`FIXME` — ссылаться на backlog или issue
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
`__fixtures__/` дописать их туда же.
|
`__fixtures__/` дописать их туда же.
|
||||||
- design-tokens правила применяются к тем же scoped-путям
|
- design-tokens правила применяются к тем же scoped-путям
|
||||||
(`src/lib/components/**`, `src/routes/**`).
|
(`src/lib/components/**`, `src/routes/**`).
|
||||||
|
- `i18n/no-hardcoded-user-text` применяется к `src/lib/components/**` (`/kit`
|
||||||
|
исключён как витрина с намеренно hardcoded-подписями) и настраивается опцией
|
||||||
|
`allowWords` —brand не хардкодится в правиле.
|
||||||
|
|
||||||
## Правила плагина design-tokens
|
## Правила плагина design-tokens
|
||||||
|
|
||||||
@@ -60,7 +63,9 @@
|
|||||||
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
||||||
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
||||||
|
|
||||||
## Правило плагина i18n (`i18n/dict-consistency`)
|
## Плагин i18n (`i18n/`)
|
||||||
|
|
||||||
|
### `i18n/dict-consistency`
|
||||||
|
|
||||||
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
||||||
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
||||||
@@ -97,6 +102,28 @@
|
|||||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
тестах подаются модифицированные варианты `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` — единственные две
|
- Цвета: `--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.
|
* new locales are picked up automatically.
|
||||||
*/
|
*/
|
||||||
import dictConsistency from "./dict-consistency.js";
|
import dictConsistency from "./dict-consistency.js";
|
||||||
|
import noHardcodedUserText from "./no-hardcoded-user-text.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
meta: {
|
meta: {
|
||||||
@@ -16,5 +17,6 @@ export default {
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"dict-consistency": dictConsistency,
|
"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__/no-string-union-alias.test.ts",
|
||||||
"eslint-plugins/__tests__/helpers.ts",
|
"eslint-plugins/__tests__/helpers.ts",
|
||||||
"eslint-plugins/__tests__/dict-consistency.test.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/dict.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
||||||
@@ -165,8 +166,9 @@ export default tseslint.config(
|
|||||||
"conventions/no-string-union-alias": "error",
|
"conventions/no-string-union-alias": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency) и запрет
|
||||||
// Правило сообщает warning, а `lint:all` превращает новые warnings в
|
// захардкоженного пользовательского текста (i18n/no-hardcoded-user-text).
|
||||||
|
// Правила сообщают warning, а `lint:all` превращает новые warnings в
|
||||||
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
|
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
|
||||||
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
|
// 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,
|
files: toolingFiles,
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
import type {
|
import type {
|
||||||
ColorPair,
|
ColorPair,
|
||||||
ColorPairSpec,
|
ColorPairSpec,
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
value={current.from}
|
value={current.from}
|
||||||
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)}
|
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>
|
||||||
<label class="pair-axis">
|
<label class="pair-axis">
|
||||||
<span class="swatch" style="background:{current.to}"></span>
|
<span class="swatch" style="background:{current.to}"></span>
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
value={current.to}
|
value={current.to}
|
||||||
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
|
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
<span class="axis-label">To</span>
|
<span class="axis-label">{t("ui.to")}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user