mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
chore: add lint rules, fix script\configs
This commit is contained in:
+10
-3
@@ -208,10 +208,17 @@
|
|||||||
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
||||||
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
||||||
правило такое (запретить toContain без i18n)
|
правило такое (запретить toContain без i18n)
|
||||||
|
- [ ] чистка под новые конвенции комментариев и тестов: правила
|
||||||
|
`conventions/ascii-only` (тесты — ASCII), `conventions/comments-english`
|
||||||
|
(комментарии — английский), `conventions/comment-format` (≤5 строк на
|
||||||
|
блок, ≤15% файла) уже работают в warn-режиме, но в `lint:all` отключены
|
||||||
|
через override в `web/scripts/lint-all.mjs` (baseline ≠ 0). Критерий
|
||||||
|
закрытия: ноль срабатываний по всем трём правилам, overrides удалены,
|
||||||
|
правила переведены с warn на error
|
||||||
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
|
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
|
||||||
и т.д., сделать lint правило
|
и т.д. — часть чистки под `conventions/ascii-only` (см. выше)
|
||||||
- [ ] комментарии на английском, lint правило
|
- [ ] комментарии на английском — часть чистки под
|
||||||
- [ ] уменьшить количество и многословность комментариев (lint правило?)
|
`conventions/comments-english` (см. выше)
|
||||||
|
|
||||||
## SEO/GEO
|
## SEO/GEO
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,34 @@ report-only (`warn`), baseline — 0 срабатываний.
|
|||||||
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
|
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
|
||||||
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
|
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
|
||||||
|
|
||||||
|
## Плагин conventions: текстовые конвенции
|
||||||
|
|
||||||
|
- `conventions/ascii-only` — в файлах, заданных scope'ом конфига (`*.test.ts`,
|
||||||
|
`*.spec.ts`, `e2e/**`), только ASCII: типографика и неанглийский текст в
|
||||||
|
`describe`/`it` и ожиданиях мешают поиску и скрывают хардкод локализованных
|
||||||
|
строк. Осознанные глифы (например `×` в пайплайне) — через опцию `allowChars`
|
||||||
|
в `eslint.config.js`. Один репорт на строку.
|
||||||
|
- `conventions/comments-english` — комментарии только на английском, по **белому
|
||||||
|
списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности
|
||||||
|
(кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы —
|
||||||
|
через опцию `allowChars`. Строковые литералы не трогаем — локализованные
|
||||||
|
значения легальны в словарях.
|
||||||
|
- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по
|
||||||
|
умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15)
|
||||||
|
— доля комментарных строк от непустых строк файла; density проверяется только
|
||||||
|
в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в
|
||||||
|
маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу
|
||||||
|
кода, а не к сжатию комментария.
|
||||||
|
|
||||||
|
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
|
||||||
|
`*.config.*` живут по тем же конвенциям (dogfooding, их baseline вычищен до
|
||||||
|
нуля). Фикстуры и тесты правил (`__fixtures__/`, `__tests__/`) — намеренные
|
||||||
|
входные кейсы и из scope исключены. В `src/` baseline ненулевой, поэтому все три
|
||||||
|
правила введены warn-only и в `lint:all` отключены через override в
|
||||||
|
`scripts/lint-all.mjs`, пока не сделана чистка (`docs/backlog.md`, раздел «Тесты
|
||||||
|
и lint»). Тесты: `__tests__/ascii-only.test.ts`,
|
||||||
|
`__tests__/comments-english.test.ts`, `__tests__/comment-format.test.ts`.
|
||||||
|
|
||||||
## Токены-префиксы (целевой словарь дизайна)
|
## Токены-префиксы (целевой словарь дизайна)
|
||||||
|
|
||||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Tests for the conventions/ascii-only rule
|
||||||
|
// (web/eslint-plugins/conventions/ascii-only.js).
|
||||||
|
//
|
||||||
|
// Pure file-content check, no filesystem: standard RuleTester with the
|
||||||
|
// tseslint parser. The scoping to test files lives in eslint.config.js, so
|
||||||
|
// filenames here are arbitrary.
|
||||||
|
import { RuleTester } from "eslint";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
import asciiOnly from "../conventions/ascii-only.js";
|
||||||
|
import { asRuleModule } from "./helpers.js";
|
||||||
|
|
||||||
|
RuleTester.describe = describe;
|
||||||
|
RuleTester.it = it;
|
||||||
|
|
||||||
|
const tester = new RuleTester({
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tester.run("ascii-only", asRuleModule(asciiOnly), {
|
||||||
|
valid: [
|
||||||
|
{ code: "const x = 1;", filename: "a.test.ts" },
|
||||||
|
{
|
||||||
|
code: "describe('resize pipeline', () => {});",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Glyphs listed in allowChars pass.
|
||||||
|
code: "expect(label).toBe('512×512');",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
options: [{ allowChars: ["×"] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
code: "it('ресайзит картинку', () => {});",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "// arrow → here\nconst x = 1;",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Em dash and ellipsis on one line collapse to a single report.
|
||||||
|
code: "it('a — b … c', () => {});",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Two offending lines produce two reports.
|
||||||
|
code: "// über\n// café\nconst x = 1;",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// allowChars does not whitelist other glyphs on the same line.
|
||||||
|
code: "expect(s).toBe('512×512 → done');",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
options: [{ allowChars: ["×"] }],
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Control characters (here: vertical tab) are not printable ASCII.
|
||||||
|
code: "const s = '\x0B';",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// DEL (U+007F) is not printable either.
|
||||||
|
code: "const s = '\x7F';",
|
||||||
|
filename: "a.test.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Tests for the conventions/comment-format rule
|
||||||
|
// (web/eslint-plugins/conventions/comment-format.js).
|
||||||
|
//
|
||||||
|
// Pure AST check (comment nodes + line counts), standard RuleTester.
|
||||||
|
import { RuleTester } from "eslint";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
import commentFormat from "../conventions/comment-format.js";
|
||||||
|
import { asRuleModule } from "./helpers.js";
|
||||||
|
|
||||||
|
RuleTester.describe = describe;
|
||||||
|
RuleTester.it = it;
|
||||||
|
|
||||||
|
const tester = new RuleTester({
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const longBlock = [
|
||||||
|
"// one",
|
||||||
|
"// two",
|
||||||
|
"// three",
|
||||||
|
"// four",
|
||||||
|
"// five",
|
||||||
|
"// six",
|
||||||
|
"const x = 1;",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
tester.run("comment-format", asRuleModule(commentFormat), {
|
||||||
|
// Density is off (maxRatio: 1) in the block-focused cases: tiny snippets
|
||||||
|
// are comment-dominated by construction.
|
||||||
|
valid: [
|
||||||
|
{
|
||||||
|
code: "// why\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "// a\n// b\n// c\n// d\n// e\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Blank line breaks the run: two 3-line blocks, both under the cap.
|
||||||
|
code: "// a\n// b\n// c\n\n// d\n// e\n// f\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Trailing inline comment does not extend the block above it.
|
||||||
|
code: "// a\n// b\nconst x = 1; // tail",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A wider block cap admits the same comment.
|
||||||
|
code: longBlock,
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxBlockLines: 6, maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Density is skipped below minLines: small files are exempt.
|
||||||
|
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxBlockLines: 5, maxRatio: 0.4, minLines: 10 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
code: longBlock,
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
errors: [{ messageId: "blockTooLong" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A single block comment counts its full line span.
|
||||||
|
code: "/*\n1\n2\n3\n4\n5\n6\n*/\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
errors: [{ messageId: "blockTooLong" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Density: 2 comment lines against 2 code lines is 50%.
|
||||||
|
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxBlockLines: 5, maxRatio: 0.4 }],
|
||||||
|
errors: [{ messageId: "tooDense" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Both violations in one file produce two reports.
|
||||||
|
code: longBlock + "\n// extra",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ maxBlockLines: 5, maxRatio: 0.5 }],
|
||||||
|
errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Tests for the conventions/comments-english rule
|
||||||
|
// (web/eslint-plugins/conventions/comments-english.js).
|
||||||
|
//
|
||||||
|
// Pure AST check over comment nodes with an ASCII whitelist; string literals
|
||||||
|
// are ignored by construction (they are not comments), which the cases below
|
||||||
|
// pin down.
|
||||||
|
import { RuleTester } from "eslint";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
import commentsEnglish from "../conventions/comments-english.js";
|
||||||
|
import { asRuleModule } from "./helpers.js";
|
||||||
|
|
||||||
|
RuleTester.describe = describe;
|
||||||
|
RuleTester.it = it;
|
||||||
|
|
||||||
|
const tester = new RuleTester({
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tester.run("comments-english", asRuleModule(commentsEnglish), {
|
||||||
|
valid: [
|
||||||
|
{ code: "// resize the canvas\nconst x = 1;", filename: "a.ts" },
|
||||||
|
{ code: "/* multi\nline */\nconst x = 1;", filename: "a.ts" },
|
||||||
|
{
|
||||||
|
// Localized strings are dictionary content, not comments.
|
||||||
|
code: "const title = 'Пример';",
|
||||||
|
filename: "a.ts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Glyphs listed in allowChars pass.
|
||||||
|
code: "// size 512×512\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ allowChars: ["×"] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A tab inside a comment is legitimate whitespace.
|
||||||
|
code: "//\tnote\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
code: "// ресайз холста\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Whitelist catches scripts beyond Cyrillic too.
|
||||||
|
code: "// 调整画布大小\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "// تغيير حجم اللوحة\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Typography in an otherwise English comment is still non-ASCII.
|
||||||
|
code: "// resize — then crop\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "/* много\nстрочный */\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "const x = 1; // хвостовой комментарий",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// One report per offending comment, not per file.
|
||||||
|
code: "// раз\n// два\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// allowChars does not whitelist other glyphs in the same comment.
|
||||||
|
code: "// 512×512 → done\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
options: [{ allowChars: ["×"] }],
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Control characters (here: vertical tab) are not printable ASCII.
|
||||||
|
code: "// \x0B note\nconst x = 1;",
|
||||||
|
filename: "a.ts",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Rule: ASCII-ONLY SOURCE (scoped to test files via eslint.config.js).
|
||||||
|
// Non-ASCII in test names and expectations hurts grep-ability and hides
|
||||||
|
// hardcoded localized strings; intentional glyphs go to the allowChars
|
||||||
|
// option. File-content based (not AST), one report per line.
|
||||||
|
// Details: web/eslint-plugins/README.md.
|
||||||
|
|
||||||
|
const DEFAULT_ALLOW = [];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: "suggestion",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Require ASCII-only source in scoped files (tests); exceptions go to the allowChars option.",
|
||||||
|
category: "Conventions",
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
allowChars: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", minLength: 1, maxLength: 1 },
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: {
|
||||||
|
nonAscii:
|
||||||
|
"Non-ASCII character {{chars}} on this line. Tests are written in plain ASCII English; keep intentional glyphs in the rule's allowChars option.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const allowed = new Set(context.options[0]?.allowChars ?? DEFAULT_ALLOW);
|
||||||
|
return {
|
||||||
|
Program() {
|
||||||
|
const sourceCode = context.sourceCode;
|
||||||
|
const text = sourceCode.getText();
|
||||||
|
const reportedLines = new Set();
|
||||||
|
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
|
||||||
|
for (const match of text.matchAll(/[^\x09\x0A\x0D\x20-\x7E]/g)) {
|
||||||
|
const char = match[0];
|
||||||
|
if (allowed.has(char)) continue;
|
||||||
|
const loc = sourceCode.getLocFromIndex(match.index);
|
||||||
|
if (reportedLines.has(loc.line)) continue;
|
||||||
|
reportedLines.add(loc.line);
|
||||||
|
context.report({
|
||||||
|
loc,
|
||||||
|
messageId: "nonAscii",
|
||||||
|
data: {
|
||||||
|
chars: `'${char}' (U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Rule: COMMENT FORMAT: bounded blocks and bounded density.
|
||||||
|
// maxBlockLines caps one comment block; maxRatio caps the share of comment
|
||||||
|
// lines in files with minLines+ non-blank lines. Warn-only tripwires:
|
||||||
|
// excess means refactor the code, not squeeze the comment.
|
||||||
|
// Details: web/eslint-plugins/README.md.
|
||||||
|
|
||||||
|
function lineSpan(comment) {
|
||||||
|
return comment.loc.end.line - comment.loc.start.line + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: "suggestion",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Cap comment block length and overall comment density per file.",
|
||||||
|
category: "Conventions",
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
maxBlockLines: { type: "integer", minimum: 1 },
|
||||||
|
maxRatio: { type: "number", minimum: 0, maximum: 1 },
|
||||||
|
minLines: { type: "integer", minimum: 0 },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: {
|
||||||
|
blockTooLong:
|
||||||
|
"Comment block is {{lines}} lines (limit {{max}}). Explain 'why' briefly; move 'what' into names, structure, or a linked doc.",
|
||||||
|
tooDense:
|
||||||
|
"Comments take {{percent}}% of this file (limit {{max}}%). Prefer extracting functions/modules over narrating the code.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const maxBlockLines = context.options[0]?.maxBlockLines ?? 5;
|
||||||
|
const maxRatio = context.options[0]?.maxRatio ?? 0.15;
|
||||||
|
const minLines = context.options[0]?.minLines ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
const sourceCode = context.sourceCode;
|
||||||
|
const comments = sourceCode.getAllComments();
|
||||||
|
if (comments.length === 0) return;
|
||||||
|
|
||||||
|
const commentLines = new Set();
|
||||||
|
let blockStart = comments[0];
|
||||||
|
let blockEndLine = comments[0].loc.end.line;
|
||||||
|
let blockLines = lineSpan(comments[0]);
|
||||||
|
|
||||||
|
const flushBlock = () => {
|
||||||
|
if (blockLines > maxBlockLines) {
|
||||||
|
context.report({
|
||||||
|
loc: blockStart.loc,
|
||||||
|
messageId: "blockTooLong",
|
||||||
|
data: { lines: String(blockLines), max: String(maxBlockLines) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < comments.length; i++) {
|
||||||
|
const comment = comments[i];
|
||||||
|
for (let l = comment.loc.start.line; l <= comment.loc.end.line; l++) {
|
||||||
|
commentLines.add(l);
|
||||||
|
}
|
||||||
|
if (i === 0) continue;
|
||||||
|
// Adjacent `//` lines form one block; anything else starts a new one.
|
||||||
|
if (
|
||||||
|
comment.type === "Line" &&
|
||||||
|
comments[i - 1].type === "Line" &&
|
||||||
|
comment.loc.start.line === blockEndLine + 1
|
||||||
|
) {
|
||||||
|
blockEndLine = comment.loc.end.line;
|
||||||
|
blockLines += 1;
|
||||||
|
} else {
|
||||||
|
flushBlock();
|
||||||
|
blockStart = comment;
|
||||||
|
blockEndLine = comment.loc.end.line;
|
||||||
|
blockLines = lineSpan(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBlock();
|
||||||
|
|
||||||
|
const totalLines = sourceCode.lines.filter(
|
||||||
|
(l) => l.trim() !== "",
|
||||||
|
).length;
|
||||||
|
const ratio = commentLines.size / totalLines;
|
||||||
|
if (totalLines >= minLines && ratio > maxRatio) {
|
||||||
|
context.report({
|
||||||
|
loc: node.loc,
|
||||||
|
messageId: "tooDense",
|
||||||
|
data: {
|
||||||
|
percent: (ratio * 100).toFixed(1),
|
||||||
|
max: (maxRatio * 100).toFixed(1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Rule: COMMENTS IN ENGLISH ONLY. Whitelist, not blacklist: a comment must
|
||||||
|
// be plain ASCII, so Arabic/CJK/Korean are caught just like Cyrillic.
|
||||||
|
// Intentional glyphs go to the allowChars option. String literals are NOT
|
||||||
|
// checked: localized dictionary values legitimately contain other scripts.
|
||||||
|
// Details: web/eslint-plugins/README.md.
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: "suggestion",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Require comments to be written in English (ASCII whitelist; exceptions via allowChars).",
|
||||||
|
category: "Conventions",
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
allowChars: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", minLength: 1, maxLength: 1 },
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: {
|
||||||
|
nonAscii:
|
||||||
|
"Comment contains non-ASCII text ({{chars}}). Write comments in English; localized user-facing strings belong in lib/i18n dictionaries.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const allowed = new Set(context.options[0]?.allowChars ?? []);
|
||||||
|
return {
|
||||||
|
Program() {
|
||||||
|
for (const comment of context.sourceCode.getAllComments()) {
|
||||||
|
const offenders = new Set();
|
||||||
|
for (const match of comment.value.matchAll(
|
||||||
|
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
|
||||||
|
/[^\x09\x0A\x0D\x20-\x7E]/g,
|
||||||
|
)) {
|
||||||
|
if (!allowed.has(match[0])) offenders.add(match[0]);
|
||||||
|
}
|
||||||
|
if (offenders.size === 0) continue;
|
||||||
|
context.report({
|
||||||
|
loc: comment.loc,
|
||||||
|
messageId: "nonAscii",
|
||||||
|
data: {
|
||||||
|
chars: [...offenders]
|
||||||
|
.map(
|
||||||
|
(c) =>
|
||||||
|
`'${c}' (U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
|
||||||
|
)
|
||||||
|
.join(", "),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Local ESLint plugin "conventions".
|
* Local ESLint plugin "conventions": cross-cutting code conventions the
|
||||||
*
|
* recommended rule sets do not enforce. Rule details:
|
||||||
* Cross-cutting code conventions that the recommended rule sets don't enforce
|
* web/eslint-plugins/README.md.
|
||||||
* (and plain @typescript-eslint rules cannot express in one shot):
|
|
||||||
*
|
|
||||||
* - interface-props: Svelte 5 props always go through a local `interface
|
|
||||||
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
|
|
||||||
* type imports, no untyped destructuring);
|
|
||||||
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
|
|
||||||
* 'b'`) are banned in favor of a single `as const` object + indexed access,
|
|
||||||
* so literal sets live in exactly one place.
|
|
||||||
*
|
|
||||||
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
|
|
||||||
*/
|
*/
|
||||||
|
import asciiOnly from "./ascii-only.js";
|
||||||
|
import commentFormat from "./comment-format.js";
|
||||||
|
import commentsEnglish from "./comments-english.js";
|
||||||
import interfaceProps from "./interface-props.js";
|
import interfaceProps from "./interface-props.js";
|
||||||
import noStringUnionAlias from "./no-string-union-alias.js";
|
import noStringUnionAlias from "./no-string-union-alias.js";
|
||||||
|
|
||||||
@@ -22,6 +15,9 @@ export default {
|
|||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"ascii-only": asciiOnly,
|
||||||
|
"comment-format": commentFormat,
|
||||||
|
"comments-english": commentsEnglish,
|
||||||
"interface-props": interfaceProps,
|
"interface-props": interfaceProps,
|
||||||
"no-string-union-alias": noStringUnionAlias,
|
"no-string-union-alias": noStringUnionAlias,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,28 +1,8 @@
|
|||||||
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
|
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props` (AGENTS.md).
|
||||||
//
|
// Banned: the inline generic `$props<T>()`, untyped destructuring, binding
|
||||||
// Convention (AGENTS.md): every typed props of a component is described by a
|
// the whole `$props()` object, non-local props type names. Purely syntactic
|
||||||
// local `interface Props`, and the props are destructured with the annotation:
|
// check; inline import('...') types are already covered by
|
||||||
//
|
// @typescript-eslint/consistent-type-imports.
|
||||||
// interface Props {
|
|
||||||
// label: string;
|
|
||||||
// accent?: boolean;
|
|
||||||
// children?: Snippet;
|
|
||||||
// }
|
|
||||||
// let { label, accent = false, children }: Props = $props();
|
|
||||||
//
|
|
||||||
// Banned instead:
|
|
||||||
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
|
|
||||||
// type away from the file structure;
|
|
||||||
// - untyped destructuring `let { ... } = $props()`;
|
|
||||||
// - binding the whole props object (`const props = $props()`);
|
|
||||||
// - any props type name other than the local `interface Props`.
|
|
||||||
//
|
|
||||||
// Inline `import('...')` type queries are NOT checked here: they are already
|
|
||||||
// banned by @typescript-eslint/consistent-type-imports.
|
|
||||||
//
|
|
||||||
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
|
|
||||||
// $props() only exists in instance <script> blocks, so no scope/type info is
|
|
||||||
// needed. Interfaces are collected from every <script> (module + instance).
|
|
||||||
|
|
||||||
import { isInsideScriptElement } from "./utils.js";
|
import { isInsideScriptElement } from "./utils.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
|
// Rule: NO STRING-LITERAL UNION TYPE ALIASES. A `type Kind = 'a' | 'b'`
|
||||||
//
|
// alias duplicates the literal set across files and derived aliases drift
|
||||||
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
|
// from the source; prefer one `as const` object + indexed access. Only
|
||||||
// duplicates the literal set: the same set is re-typed in many places, and new
|
// TSTypeAliasDeclaration is checked. Details: web/eslint-plugins/README.md.
|
||||||
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
|
|
||||||
// source. Prefer a single `as const` object as the source of truth and derive
|
|
||||||
// the type from it with indexed access.
|
|
||||||
//
|
|
||||||
// const KIND = { a: ..., b: ..., c: ... } as const;
|
|
||||||
// type Kind = (typeof KIND)[keyof typeof KIND];
|
|
||||||
//
|
|
||||||
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
|
|
||||||
// property types (one-off uses) are left alone.
|
|
||||||
|
|
||||||
/** True when the union and all its (possibly nested) members are string literals. */
|
/** True when the union and all its (possibly nested) members are string literals. */
|
||||||
function allStringLiterals(/** @type {any} */ union) {
|
function allStringLiterals(/** @type {any} */ union) {
|
||||||
|
|||||||
@@ -16,31 +16,21 @@ export const SIZE_PROPS =
|
|||||||
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
|
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// SHORTHAND properties that accept BOTH a color and a size. The browser
|
// SHORTHANDS taking BOTH a color and a size: the browser assigns
|
||||||
// assigns their sub-properties by value type at runtime (length -> width,
|
// sub-properties by value type, so no positional category check. Their
|
||||||
// color -> ...-color), so they cannot be category-checked positionally.
|
// longhands are covered by SIZE_PROPS / COLOR_PROPS individually.
|
||||||
// border -> border-width + border-color
|
|
||||||
// outline -> outline-width + outline-color
|
|
||||||
// text-decoration -> text-decoration-line/-color/...
|
|
||||||
// column-rule -> column-rule-width + column-rule-color
|
|
||||||
// The longhands they expand to (border-width, border-color, ...) are already
|
|
||||||
// covered individually by SIZE_PROPS / COLOR_PROPS.
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
export const MIXED_PROPS =
|
export const MIXED_PROPS =
|
||||||
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
|
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Properties that carry a DURATION (ms/s) — transitions/animations.
|
// Properties that carry a DURATION (ms/s): transitions/animations.
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
export const DURATION_PROPS =
|
export const DURATION_PROPS =
|
||||||
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
|
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// A hardcoded COLOR literal: hex / color functions / named colors.
|
// A hardcoded COLOR literal: hex / color functions / named colors.
|
||||||
// Regex legend:
|
|
||||||
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
|
|
||||||
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
|
|
||||||
// - names: common CSS color names on a word boundary
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
export const COLOR_LITERAL =
|
export const COLOR_LITERAL =
|
||||||
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
|
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
|
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS. A size property must use
|
||||||
// A size property (padding, gap, font-size ...) must use a SIZE token
|
// a SIZE token (--space-*, --size-*, ...), a color property a COLOR token
|
||||||
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
|
// (--color-*, --brand-*). Crossing categories (padding: var(--color-x))
|
||||||
// (color, background, border-color ...) must use a COLOR token
|
// means the wrong token is being reused.
|
||||||
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
|
|
||||||
// is a sign the wrong token is being reused.
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COLOR_PROPS,
|
COLOR_PROPS,
|
||||||
@@ -56,12 +54,10 @@ export default {
|
|||||||
const prop = decl.prop ?? "";
|
const prop = decl.prop ?? "";
|
||||||
const value = decl.value ?? "";
|
const value = decl.value ?? "";
|
||||||
|
|
||||||
// Mixed shorthands (border, outline, text-decoration,
|
// Mixed shorthands (border, outline, ...) legitimately take
|
||||||
// column-rule) legitimately take BOTH a size and a color.
|
// BOTH a size and a color: the browser assigns sub-properties
|
||||||
// The browser assigns sub-properties by value type at runtime,
|
// by value type, not by position, so skip category checks.
|
||||||
// not by position, so skip category checks for them.
|
// Their longhands are still covered individually.
|
||||||
// Their longhands (border-width, border-color, ...) are still
|
|
||||||
// covered individually.
|
|
||||||
if (MIXED_PROPS.test(prop)) return;
|
if (MIXED_PROPS.test(prop)) return;
|
||||||
|
|
||||||
const isSizeProp = SIZE_PROPS.test(prop);
|
const isSizeProp = SIZE_PROPS.test(prop);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
|
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
|
||||||
// Everything visual in a Svelte <style> block must come from design tokens
|
// Everything visual in a Svelte <style> block must come from design tokens
|
||||||
// (var(--...)); direct literals are banned. color-mix() is banned too — its
|
// (var(--...)); direct literals are banned. color-mix() is banned too: its
|
||||||
// result must be tokenized in the design CSS file.
|
// result must be tokenized in the design CSS file.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
} from "./lists.js";
|
} from "./lists.js";
|
||||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||||
|
|
||||||
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
|
// Strip var(...) bodies so tokens inside are never mistaken for literals.
|
||||||
// mistaken for literals.
|
|
||||||
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
|
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -64,16 +63,12 @@ export default {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// =====================================================================
|
// --- NO HARDCODED BREAKPOINTS IN @media ---
|
||||||
// 3) NO HARDCODED BREAKPOINTS IN @media
|
// (max|min)-width followed by a NON-var( number is a raw length.
|
||||||
// @media conditions must use --bp-* tokens, not raw lengths.
|
|
||||||
// Regex: (max|min)-width followed by a NON-var(() number.
|
|
||||||
// =====================================================================
|
|
||||||
const BREAKPOINT_RE =
|
const BREAKPOINT_RE =
|
||||||
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
|
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
|
||||||
|
|
||||||
// Custom properties do not resolve inside @media conditions, so any
|
// Custom properties do not resolve in @media: var(--bp-*) there is a latent bug.
|
||||||
// var(--bp-*) inside @media is a latent bug — the block never applies.
|
|
||||||
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
||||||
|
|
||||||
// Sub-rule 6: color-mix() is banned anywhere in a component style.
|
// Sub-rule 6: color-mix() is banned anywhere in a component style.
|
||||||
@@ -123,12 +118,10 @@ export default {
|
|||||||
// Only inspect known layout properties from here on.
|
// Only inspect known layout properties from here on.
|
||||||
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
|
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
|
||||||
|
|
||||||
// Sub-rule 2: sizes.
|
// Sub-rule 2: sizes. Fire only on a real px/rem/em token:
|
||||||
// Fire only on a real remaining px/rem/em token — percentages
|
// percentages and unitless values stay legal, a 0px reset is
|
||||||
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
|
// the only legal absolute length. MIXED_PROPS shorthands are
|
||||||
// The only legal absolute length is a 0px reset line.
|
// checked for their SIZE half too.
|
||||||
// MIXED_PROPS (border/outline shorthands) are checked for their
|
|
||||||
// SIZE half too — a border width must come from var(--size-*).
|
|
||||||
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
|
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
|
||||||
const withoutVars = stripVars(value);
|
const withoutVars = stripVars(value);
|
||||||
const reported = new Set();
|
const reported = new Set();
|
||||||
@@ -141,9 +134,8 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub-rule 1: colors.
|
// Sub-rule 1: colors. After stripping allowed keywords
|
||||||
// Remaining value must be only allowed keywords
|
// (currentColor/transparent/inherit/none/0), any color
|
||||||
// (currentColor/transparent/inherit/none/0); otherwise a color
|
|
||||||
// literal is reported.
|
// literal is reported.
|
||||||
if (COLOR_PROPS.test(prop)) {
|
if (COLOR_PROPS.test(prop)) {
|
||||||
const withoutVars = stripVars(value);
|
const withoutVars = stripVars(value);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
|
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
|
||||||
// A custom property may be DEFINED inside a component's <style> block, but its
|
// A custom property may be DEFINED in a component's <style>, but its value
|
||||||
// value must not introduce a design primitive: a raw color literal (hex/rgb/
|
// must not introduce a design primitive (raw color, absolute size): the
|
||||||
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
|
// design surface stays in app.css, while local DERIVED variables (var(),
|
||||||
// (colors/sizes) a single source of truth in app.css, while still allowing
|
// calc(), unitless ratios) remain legitimate.
|
||||||
// local DERIVED variables built from tokens: var(--...), calc(), unitless
|
|
||||||
// ratios (--ratio: 1.5) — those are legitimate component-local state.
|
|
||||||
|
|
||||||
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
|
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
|
||||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||||
@@ -57,7 +55,7 @@ export default {
|
|||||||
|
|
||||||
const primitive = stripVars(value);
|
const primitive = stripVars(value);
|
||||||
|
|
||||||
// A color literal in the definition → primitive.
|
// A color literal in the definition is a primitive.
|
||||||
const colorHit = primitive.match(COLOR_LITERAL);
|
const colorHit = primitive.match(COLOR_LITERAL);
|
||||||
if (colorHit) {
|
if (colorHit) {
|
||||||
report(decl, "tokenPrimitive", {
|
report(decl, "tokenPrimitive", {
|
||||||
@@ -67,7 +65,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An absolute size (px/rem/em) in the definition → primitive.
|
// An absolute size (px/rem/em) in the definition is a primitive.
|
||||||
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
|
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
|
||||||
if (sizeHit) {
|
if (sizeHit) {
|
||||||
report(decl, "tokenPrimitive", {
|
report(decl, "tokenPrimitive", {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
|||||||
|
|
||||||
// A custom property DEFINITION: "--x:" followed by a colon.
|
// A custom property DEFINITION: "--x:" followed by a colon.
|
||||||
const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
|
const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
|
||||||
// A var() REFERENCE (primary argument only — fallbacks are optional overrides).
|
// A var() REFERENCE (primary argument only; fallbacks are optional overrides).
|
||||||
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
|
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
|
||||||
|
|
||||||
const definedCache = new Map();
|
const definedCache = new Map();
|
||||||
|
|||||||
@@ -1,30 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Cross-locale dictionary linter (i18n/dict-consistency).
|
* Cross-locale dictionary linter: each dict in lib/i18n/ is checked against
|
||||||
*
|
* ALL others (key parity, empty values, placeholders), warn-only.
|
||||||
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just
|
* Full semantics and options: web/eslint-plugins/README.md.
|
||||||
* 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 BASE_LOCALE
|
|
||||||
* then 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.
|
|
||||||
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
|
|
||||||
* - ignoreMissingPatterns: patterns temporarily excluded from missing-key
|
|
||||||
* checks.
|
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -54,7 +31,7 @@ function unwrap(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The exported dict `export const <name>: Dict = {...}` — the declarator id
|
* The exported dict `export const <name>: Dict = {...}`: the declarator id
|
||||||
* (for reporting) and the object literal, or null.
|
* (for reporting) and the object literal, or null.
|
||||||
*/
|
*/
|
||||||
function findDictExport(ast) {
|
function findDictExport(ast) {
|
||||||
@@ -109,7 +86,7 @@ function findLocalesList(ast) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* String literal node. The parser emits `Literal` in some ESTree versions and
|
* String literal node. The parser emits `Literal` in some ESTree versions and
|
||||||
* `StringLiteral` in others — accept both.
|
* `StringLiteral` in others; accept both.
|
||||||
*/
|
*/
|
||||||
function isStringLiteral(node) {
|
function isStringLiteral(node) {
|
||||||
if (!node) return false;
|
if (!node) return false;
|
||||||
@@ -148,10 +125,9 @@ function propKey(prop) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten an exported dict object into `path -> { node, kind, value }`.
|
* Flatten an exported dict into `path -> { node, kind, value }`. Computed
|
||||||
* Computed properties are skipped; intermediate objects are recorded too, so a
|
* properties are skipped; intermediate objects are recorded too, so a
|
||||||
* missing whole section is reported once (children of a missing path are
|
* missing whole section is reported once.
|
||||||
* skipped when reporting).
|
|
||||||
*/
|
*/
|
||||||
function buildKeyMap(objectNode) {
|
function buildKeyMap(objectNode) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -226,7 +202,7 @@ function readDir(dir) {
|
|||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
const file = path.join(dir, `${locale}.ts`);
|
const file = path.join(dir, `${locale}.ts`);
|
||||||
if (!fs.existsSync(file)) {
|
if (!fs.existsSync(file)) {
|
||||||
// Declared in LOCALES but not yet translated — treated as an empty
|
// Declared in LOCALES but not yet translated; treated as an empty
|
||||||
// dict so every key it lacks reports `missing key ... in <locale>`.
|
// dict so every key it lacks reports `missing key ... in <locale>`.
|
||||||
dicts.push({ locale, map: new Map() });
|
dicts.push({ locale, map: new Map() });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Local ESLint plugin "i18n".
|
* Local ESLint plugin "i18n": cross-locale dictionary hygiene
|
||||||
*
|
* (dict-consistency) and no hardcoded user text in templates. Warn-only by
|
||||||
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in
|
* design. Rule details: web/eslint-plugins/README.md.
|
||||||
* `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";
|
import dictConsistency from "./dict-consistency.js";
|
||||||
import noHardcodedUserText from "./no-hardcoded-user-text.js";
|
import noHardcodedUserText from "./no-hardcoded-user-text.js";
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
|
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES. Every user-visible
|
||||||
//
|
// string must come from the dictionaries via t(); a literal silently ships
|
||||||
// Every user-visible string must come from the dictionaries via t(); a literal in
|
// English to the RU locale. Warn-only report-only pilot. Exclusion list
|
||||||
// the template silently ships English to the RU locale. Warn-only by design
|
// (brand, units, registry strings, /kit): web/eslint-plugins/README.md.
|
||||||
// (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";
|
import { USER_TEXT_ATTRS } from "./lists.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Local ESLint plugin "design-tokens".
|
* Local ESLint plugin "design-tokens": hardcoded colors/sizes are banned in
|
||||||
*
|
* Svelte <style> blocks, everything comes from CSS tokens in app.css.
|
||||||
* Goal: a single source of truth for design. In Svelte components, hardcoded
|
* Rule details: web/eslint-plugins/README.md.
|
||||||
* colors and sizes are banned; everything must come from CSS variables (tokens
|
|
||||||
* in app.css). Rules inspect the postcss AST of Svelte <style> blocks
|
|
||||||
* exposed by svelte-eslint-parser.
|
|
||||||
*/
|
*/
|
||||||
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
|
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
|
||||||
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
|
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
|
||||||
|
|||||||
+96
-38
@@ -8,28 +8,27 @@ import designTokens from "./eslint-plugins/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";
|
import i18nPlugin from "./eslint-plugins/i18n/index.js";
|
||||||
|
|
||||||
// Production-UI: к нему применяются полные recommended-наборы.
|
// Production UI: the full recommended sets apply to it.
|
||||||
const productionCode = ["**/src/lib/components/**", "**/src/routes/**"];
|
const productionCode = ["**/src/lib/components/**", "**/src/routes/**"];
|
||||||
|
|
||||||
// Инфраструктура линтинга: сами плагины, скрипты и конфиги. Это не
|
// Lint tooling: the plugins, gate scripts and configs. Not product code,
|
||||||
// продуктовый код, но всё это влияет на гейты, поэтому к нему тоже
|
// but it feeds the gates, so the base JS recommended set applies; the full
|
||||||
// применяется базовый JS recommended. Полные TS/svelte-наборы здесь не
|
// TS/svelte sets do not (.mjs/.js only).
|
||||||
// подключаются: файлы .mjs и .js проверяются только базовым набором.
|
|
||||||
const toolingFiles = [
|
const toolingFiles = [
|
||||||
"**/eslint-plugins/**/*.js",
|
"**/eslint-plugins/**/*.js",
|
||||||
"**/scripts/**/*.mjs",
|
"**/scripts/**/*.mjs",
|
||||||
"*.config.js",
|
"*.config.js",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Полные recommended-наборы — только на production-код (см. ниже, блок перед prettier).
|
// Full recommended sets apply to production code only (block before prettier).
|
||||||
const jsRecommended = Array.isArray(js.configs.recommended)
|
const jsRecommended = Array.isArray(js.configs.recommended)
|
||||||
? js.configs.recommended
|
? js.configs.recommended
|
||||||
: [js.configs.recommended];
|
: [js.configs.recommended];
|
||||||
const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"])
|
const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"])
|
||||||
? svelte.configs["flat/recommended"]
|
? svelte.configs["flat/recommended"]
|
||||||
: [svelte.configs["flat/recommended"]];
|
: [svelte.configs["flat/recommended"]];
|
||||||
// Svelte-рекомендации применяем только к .svelte-файлам production-кода, иначе
|
// Svelte recommended targets production .svelte files only, otherwise
|
||||||
// svelte-eslint-parser "съедает" обычные .ts в тех же папках (напр. +page.ts).
|
// svelte-eslint-parser swallows plain .ts in the same folders (+page.ts).
|
||||||
const productionSvelte = [
|
const productionSvelte = [
|
||||||
"**/src/lib/components/**/*.svelte",
|
"**/src/lib/components/**/*.svelte",
|
||||||
"**/src/routes/**/*.svelte",
|
"**/src/routes/**/*.svelte",
|
||||||
@@ -45,7 +44,7 @@ export default tseslint.config(
|
|||||||
"**/static/**",
|
"**/static/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Минимально на весь код: парсинг TS/Svelte + запрет инлайн-тип-импортов.
|
// Minimum on all code: TS/Svelte parsing + ban on inline type imports.
|
||||||
{
|
{
|
||||||
files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte.js", "**/*.svelte"],
|
files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte.js", "**/*.svelte"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -59,17 +58,18 @@ export default tseslint.config(
|
|||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"eslint.config.js",
|
"eslint.config.js",
|
||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
// e2e покрыты e2e/tsconfig.json.
|
// e2e are covered by e2e/tsconfig.json. Lint-rule tests and
|
||||||
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
// fixtures live outside src/, so typed parsing resolves them
|
||||||
// tsconfig), поэтому для типизированного парсинга резолвятся
|
// via the default project; tseslint bans `**` here.
|
||||||
// через default-проект. Перечисляются точечно: `**` в
|
|
||||||
// allowDefaultProject запрещён tseslint.
|
|
||||||
"eslint-plugins/__tests__/design-tokens.test.ts",
|
"eslint-plugins/__tests__/design-tokens.test.ts",
|
||||||
"eslint-plugins/__tests__/interface-props.test.ts",
|
"eslint-plugins/__tests__/interface-props.test.ts",
|
||||||
"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/__tests__/no-hardcoded-user-text.test.ts",
|
||||||
|
"eslint-plugins/__tests__/ascii-only.test.ts",
|
||||||
|
"eslint-plugins/__tests__/comments-english.test.ts",
|
||||||
|
"eslint-plugins/__tests__/comment-format.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",
|
||||||
@@ -90,8 +90,8 @@ export default tseslint.config(
|
|||||||
"@typescript-eslint": tseslint.plugin,
|
"@typescript-eslint": tseslint.plugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// Инлайн-тип-импорты (import('x').Y) запрещены — только
|
// Inline type imports (import('x').Y) are banned; only
|
||||||
// `import type { Y } from 'x'` наверху файла.
|
// `import type { Y } from 'x'` at the top of the file.
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
"error",
|
"error",
|
||||||
{ prefer: "type-imports", fixStyle: "separate-type-imports" },
|
{ prefer: "type-imports", fixStyle: "separate-type-imports" },
|
||||||
@@ -116,8 +116,7 @@ export default tseslint.config(
|
|||||||
"design-tokens": designTokens,
|
"design-tokens": designTokens,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
|
// Design-token rules: no hardcoded colors/sizes in style blocks.
|
||||||
// Применяется к новому коду редизайна.
|
|
||||||
{
|
{
|
||||||
files: productionSvelte,
|
files: productionSvelte,
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -130,8 +129,8 @@ export default tseslint.config(
|
|||||||
"design-tokens/no-undefined-in-svelte": "error",
|
"design-tokens/no-undefined-in-svelte": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
|
// Svelte 5 convention: props via a local `interface Props` +
|
||||||
// `let {...}: Props = $props()` (плагин conventions/interface-props).
|
// `let {...}: Props = $props()` (conventions/interface-props).
|
||||||
{
|
{
|
||||||
files: productionSvelte,
|
files: productionSvelte,
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -141,8 +140,8 @@ export default tseslint.config(
|
|||||||
"conventions/interface-props": "error",
|
"conventions/interface-props": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Запрет строковых union-алиасов в пользу `as const` объектов
|
// Ban string-literal union aliases in favor of `as const` objects
|
||||||
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты production-кода.
|
// (conventions/no-string-union-alias).
|
||||||
{
|
{
|
||||||
files: productionCode,
|
files: productionCode,
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -152,11 +151,9 @@ export default tseslint.config(
|
|||||||
"conventions/no-string-union-alias": "error",
|
"conventions/no-string-union-alias": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency) и запрет
|
// Cross-locale dictionary linter (i18n/dict-consistency). Reports warn;
|
||||||
// захардкоженного пользовательского текста (i18n/no-hardcoded-user-text).
|
// `lint:all` makes any new warning blocking via --max-warnings=0. The
|
||||||
// Правила сообщают warning, а `lint:all` превращает новые warnings в
|
// locale list comes from LOCALES in lib/i18n/dict.ts.
|
||||||
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
|
|
||||||
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
|
|
||||||
{
|
{
|
||||||
files: ["**/src/lib/i18n/*.ts"],
|
files: ["**/src/lib/i18n/*.ts"],
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -173,9 +170,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Хардкод пользовательского текста ловим и в components, и в routes:
|
// Hardcoded user text is caught in components and routes alike:
|
||||||
// `+page.svelte`/`+layout.svelte` — такой же production-UI, иначе
|
// `+page.svelte` is production UI too, a hardcoded string silently
|
||||||
// захардкоженная строка молча уедет в RU-локаль.
|
// ships in the RU locale.
|
||||||
files: productionSvelte,
|
files: productionSvelte,
|
||||||
plugins: {
|
plugins: {
|
||||||
i18n: i18nPlugin,
|
i18n: i18nPlugin,
|
||||||
@@ -184,21 +181,82 @@ export default tseslint.config(
|
|||||||
"i18n/no-hardcoded-user-text": [
|
"i18n/no-hardcoded-user-text": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
// Название продукта — бренд, а не переводимый текст.
|
// The product name is a brand, not translatable text.
|
||||||
allowWords: ["easy-png-tools"],
|
allowWords: ["easy-png-tools"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Исключение для /kit — витрина компонентов с намеренно латинскими
|
// /kit exception: a showcase with intentionally Latin labels. Separate
|
||||||
// подписями. Отдельный объект, а не негативный glob в `files` выше:
|
// object: negative globs in `files` do not narrow a rule's scope.
|
||||||
// негативные паттерны не сужают область действия правила.
|
|
||||||
files: ["**/src/routes/kit/**/*.svelte"],
|
files: ["**/src/routes/kit/**/*.svelte"],
|
||||||
rules: {
|
rules: {
|
||||||
"i18n/no-hardcoded-user-text": "off",
|
"i18n/no-hardcoded-user-text": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Tests are written in ASCII English: typography and non-English prose in
|
||||||
|
// describe/it hurt grep-ability and hide hardcoded localized strings.
|
||||||
|
// Warn-only until the cleanup lands; intentional glyphs go to allowChars.
|
||||||
|
{
|
||||||
|
files: ["**/*.test.ts", "**/*.spec.ts", "**/e2e/**/*.ts"],
|
||||||
|
plugins: {
|
||||||
|
conventions: conventionsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"conventions/ascii-only": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Comments in English, in short blocks, without turning a file into an
|
||||||
|
// essay (conventions/comments-english + conventions/comment-format).
|
||||||
|
// Warn-only: excess is a cue to refactor code, not squeeze comments.
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"**/src/**/*.ts",
|
||||||
|
"**/src/**/*.svelte",
|
||||||
|
// Dogfooding: lint rules, gate scripts and configs follow the same
|
||||||
|
// comment conventions as production code.
|
||||||
|
"**/eslint-plugins/**/*.js",
|
||||||
|
"**/scripts/**/*.mjs",
|
||||||
|
"**/*.config.js",
|
||||||
|
"**/*.config.ts",
|
||||||
|
"**/*.config.mjs",
|
||||||
|
],
|
||||||
|
plugins: {
|
||||||
|
conventions: conventionsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"conventions/comments-english": "warn",
|
||||||
|
"conventions/comment-format": [
|
||||||
|
"warn",
|
||||||
|
// minLines: density only checks files with 100+ non-blank lines;
|
||||||
|
// in small files any header gives a false excess.
|
||||||
|
{ maxBlockLines: 5, maxRatio: 0.15, minLines: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Lint-rule fixtures are intentional stubs (including non-ASCII and
|
||||||
|
// non-English comments as input cases); the conventions do not apply.
|
||||||
|
files: [
|
||||||
|
"**/eslint-plugins/__fixtures__/**",
|
||||||
|
"**/eslint-plugins/__tests__/**",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"conventions/ascii-only": "off",
|
||||||
|
"conventions/comments-english": "off",
|
||||||
|
"conventions/comment-format": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: productionCode,
|
||||||
|
rules: {
|
||||||
|
"max-lines-per-function": [
|
||||||
|
"warn",
|
||||||
|
{ max: 100, skipBlankLines: true, skipComments: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: toolingFiles,
|
files: toolingFiles,
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -209,7 +267,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Полные recommended-наборы — только на production-код.
|
// Full recommended sets: production code only.
|
||||||
...[
|
...[
|
||||||
...jsRecommended.map((cfg) => ({
|
...jsRecommended.map((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
@@ -229,14 +287,14 @@ export default tseslint.config(
|
|||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
|
||||||
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и
|
// In Svelte 5 props are destructured via `let` (docs convention and our
|
||||||
// наш AGENTS.md), поэтому prefer-const на них — ложноположительный.
|
// AGENTS.md), so prefer-const is a false positive on them.
|
||||||
{
|
{
|
||||||
files: ["**/*.svelte"],
|
files: ["**/*.svelte"],
|
||||||
rules: {
|
rules: {
|
||||||
"prefer-const": "off",
|
"prefer-const": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// prettier — последним, чтобы гасить форматирующие правила из recommended.
|
// prettier goes last to silence formatting rules from the recommended sets.
|
||||||
prettier,
|
prettier,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
name: "webkit",
|
name: "webkit",
|
||||||
testMatch: browserContractTests,
|
testMatch: browserContractTests,
|
||||||
// webkit под Windows заметно медленнее и под нагрузкой случайно
|
// WebKit on Windows is noticeably slower and under load randomly
|
||||||
// превышает любые таймауты (каждый прогон — разные тесты): запас по
|
// exceeds any timeout (different tests each run): timeout headroom
|
||||||
// таймаутам + retries против флака.
|
// plus retries against flakiness.
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
expect: { timeout: 30_000 },
|
expect: { timeout: 30_000 },
|
||||||
retries: 2,
|
retries: 2,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Генерирует static/og/default.png — дефолтная OG-карточка 1200×630.
|
// Generates static/og/default.png: the default 1200x630 OG card.
|
||||||
// Запуск: pnpm --dir web gen:og. Карточка рисуется в браузере (текст, иконки),
|
// Run: pnpm --dir web gen:og. The card is rendered in a browser (text,
|
||||||
// per-tool карточки — этим же harness позже (docs/plan-seo.md S1e).
|
// icons); per-tool cards will reuse this harness (docs/plan-seo.md S1e).
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
|||||||
+21
-13
@@ -4,18 +4,10 @@ import { fileURLToPath } from "node:url";
|
|||||||
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
|
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
|
||||||
const node = process.execPath;
|
const node = process.execPath;
|
||||||
|
|
||||||
// Every lint layer of the redesign, in one command. Each step runs even if a
|
// Every lint layer in one command. Each step runs even if a previous one
|
||||||
// previous one fails; the exit code is nonzero if any did.
|
// fails; the exit code is nonzero if any did. Children inherit our stdout FD
|
||||||
//
|
// directly (no pipes), so `> out.txt` catches every byte; stylelint paints
|
||||||
// Stream wiring: children run with stdio ["ignore", 1, 1] — both stdout and
|
// even to a non-TTY, hence the explicit --no-color there.
|
||||||
// stderr are bound to OUR stdout file descriptor (shell `2>&1`). It is real FD
|
|
||||||
// inheritance, no pipes or buffering, so `> css-lint.txt` catches every byte.
|
|
||||||
// Color decisions are then the tool's own against the actual destination:
|
|
||||||
// a terminal gets colors, a redirected file gets plain text.
|
|
||||||
//
|
|
||||||
// One quirk: stylelint prints its report to stderr and paints it even when the
|
|
||||||
// destination is not a TTY. So for a non-TTY destination we pass its official
|
|
||||||
// --no-color explicitly.
|
|
||||||
const colorFlag = process.stdout.isTTY ? [] : ["--no-color"];
|
const colorFlag = process.stdout.isTTY ? [] : ["--no-color"];
|
||||||
|
|
||||||
const bins = {
|
const bins = {
|
||||||
@@ -28,10 +20,26 @@ const bins = {
|
|||||||
checkTokens: fileURLToPath(new URL("check-tokens.mjs", import.meta.url)),
|
checkTokens: fileURLToPath(new URL("check-tokens.mjs", import.meta.url)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Warn-only rules with a non-zero baseline stay out of the gate until the
|
||||||
|
// cleanup lands (docs/backlog.md, tests-and-lint section): they nag in
|
||||||
|
// `pnpm lint` and editors, but --max-warnings=0 would make the gate red.
|
||||||
|
// After the cleanup the overrides are removed and the rules become errors.
|
||||||
|
const pendingCleanupOverrides = [
|
||||||
|
"conventions/ascii-only",
|
||||||
|
"conventions/comments-english",
|
||||||
|
"conventions/comment-format",
|
||||||
|
].flatMap((rule) => ["--rule", `${rule}: off`]);
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
name: "ESLint (svelte) + design-tokens rules",
|
name: "ESLint (svelte) + design-tokens rules",
|
||||||
args: [bins.eslint, ...colorFlag, "--max-warnings=0", "."],
|
args: [
|
||||||
|
bins.eslint,
|
||||||
|
...colorFlag,
|
||||||
|
"--max-warnings=0",
|
||||||
|
...pendingCleanupOverrides,
|
||||||
|
".",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Stylelint (css) — design-token style",
|
name: "Stylelint (css) — design-token style",
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct() and
|
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct()
|
||||||
// emits plain sRGB hex colors.
|
// (literal, `from var(...)` seed, channel math) and emits sRGB hex. `from`
|
||||||
//
|
// seeds resolve from sibling custom properties (#hex, oklch(), rgb(), hct());
|
||||||
// Supported syntax (Material Design 3 HCT; hue 0–360, chroma 0–~120, tone 0–100):
|
// anything unresolvable is left as-is. HCT stays the single source of truth.
|
||||||
//
|
|
||||||
// --color-success: hct(155 40 55); // literal
|
|
||||||
// --color-muted: hct(from var(--brand-main) h 6 97); // take hue from seed
|
|
||||||
// --brand-alt: hct(from var(--brand-main) h c calc(t + 36)); // channel math
|
|
||||||
//
|
|
||||||
// `from` seeds are resolved from sibling custom properties in the same
|
|
||||||
// stylesheet (:root tokens plus the current rule's own tokens). Seeds may be
|
|
||||||
// #hex, oklch(), rgb() or another hct(). Anything the plugin cannot resolve is
|
|
||||||
// left as-is. The emitter is sRGB hex; HCT stays the single source of truth.
|
|
||||||
|
|
||||||
import { Color } from "@panmdaa/colors";
|
import { Color } from "@panmdaa/colors";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Статический сервер для e2e (Playwright webServer). Раздаёт web/build
|
// Static server for e2e (Playwright webServer). Serves web/build
|
||||||
// (adapter-static): /foo -> foo.html | foo/index.html, MIME по расширению.
|
// (adapter-static): /foo -> foo.html | foo/index.html, MIME by extension.
|
||||||
// Запуск: pnpm build && node scripts/serve-static.mjs --port 4173
|
// Run: pnpm build && node scripts/serve-static.mjs --port 4173
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { readFileSync, statSync } from "node:fs";
|
import { readFileSync, statSync } from "node:fs";
|
||||||
import { extname, join } from "node:path";
|
import { extname, join } from "node:path";
|
||||||
@@ -56,7 +56,7 @@ function serve(req, res) {
|
|||||||
res.end(body);
|
res.end(body);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
/* not found — try next candidate */
|
/* not found - try next candidate */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// Color authorship: every color value in app.css must be authored as hct()
|
// Color authorship: every color value in app.css must be authored as hct()
|
||||||
// — literals AND derived forms (hct(from var(...) h c t) with channel math) —
|
// (literals and derived forms), so the whole palette is computed through HCT
|
||||||
// so the whole palette is computed through HCT channels and output as sRGB by
|
// and output as sRGB by postcss-hct. Exceptions: the seed tokens
|
||||||
// the postcss-hct plugin. Exceptions: the seed tokens --brand-main/--brand-alt
|
// --brand-main/--brand-alt and color-mix(...) (the only sanctioned blend).
|
||||||
// (any format allowed) and color-mix(...) (the only sanctioned way to blend
|
|
||||||
// two tokens).
|
|
||||||
|
|
||||||
import { isColorValue } from "./helpers.mjs";
|
import { isColorValue } from "./helpers.mjs";
|
||||||
|
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ export function collectUsedTokens() {
|
|||||||
return used;
|
return used;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token is "colorful" when its value is a color literal — those must have a
|
// A token is "colorful" when its value is a color literal; those must have a
|
||||||
// dark-mate. Non-color tokens (fonts, radii, durations) are exempt.
|
// dark-mate. Non-color tokens (fonts, radii, durations) are exempt.
|
||||||
const COLOR_RE =
|
const COLOR_RE =
|
||||||
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab|hct|color-mix)\s*\(/i;
|
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab|hct|color-mix)\s*\(/i;
|
||||||
|
|
||||||
export const isColorValue = (value) => COLOR_RE.test(value);
|
export const isColorValue = (value) => COLOR_RE.test(value);
|
||||||
|
|
||||||
// A token is "derived" when its value references var(...) — it adapts to the
|
// A token is "derived" when its value references var(...); it adapts to the
|
||||||
// theme automatically, so it must NOT have a hardcoded dark twin.
|
// theme automatically, so it must NOT have a hardcoded dark twin.
|
||||||
export const isDerived = (value) => value.includes("var(");
|
export const isDerived = (value) => value.includes("var(");
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
// Unresolved references: every var(--x) in app.css must point at a token the
|
// Unresolved references: every var(--x) in app.css must point at a token the
|
||||||
// same file defines. A typo here silently drops the declaration, so unlike the
|
// same file defines: a typo silently drops the declaration, so this is a
|
||||||
// unused-token dust it is a defect, not a style choice.
|
// defect, not a style choice. `var(--x, fallback)` is legal without --x.
|
||||||
//
|
|
||||||
// `var(--x, fallback)` is legal without --x (CSS custom property fallback), so
|
|
||||||
// those references are exempt.
|
|
||||||
|
|
||||||
const VAR_REF_RE = /var\(\s*(--[\w-]+)\s*([,)])/g;
|
const VAR_REF_RE = /var\(\s*(--[\w-]+)\s*([,)])/g;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Unused tokens: defined in app.css but never referenced via var()
|
// Unused tokens: defined in app.css but never referenced via var()
|
||||||
// anywhere in src. A dead token is not the single source of truth — it's dust.
|
// anywhere in src. A dead token is not the single source of truth; it's dust.
|
||||||
// Reported as a warning; it does not fail the run. The `used` set is injectable
|
// Reported as a warning; it does not fail the run. The `used` set is injectable
|
||||||
// so tests can pass a fixture instead of scanning src.
|
// so tests can pass a fixture instead of scanning src.
|
||||||
|
|
||||||
|
|||||||
+7
-14
@@ -1,4 +1,4 @@
|
|||||||
// Stylelint config — design-token enforcement for easy-png-tools.
|
// Stylelint config: design-token enforcement for easy-png-tools.
|
||||||
// FWHM: keeps the design system a single source of truth. Colors and sizes must
|
// FWHM: keeps the design system a single source of truth. Colors and sizes must
|
||||||
// come from prefixed tokens; direct color values are allowed only for the two
|
// come from prefixed tokens; direct color values are allowed only for the two
|
||||||
// brand tokens.
|
// brand tokens.
|
||||||
@@ -12,15 +12,10 @@ export default {
|
|||||||
"**/static/**",
|
"**/static/**",
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
// Rule: every CSS variable must carry a system prefix so the design
|
// Every CSS variable must carry a system prefix so the design
|
||||||
// vocabulary stays a single, greppable source of truth.
|
// vocabulary stays a single greppable source of truth; the two brand
|
||||||
// The two brand tokens (brand-main / brand-alt) are the only
|
// tokens are the only exceptions. NOTE: stylelint tests the pattern
|
||||||
// exceptions that may exist as-is.
|
// WITHOUT the leading "--", so the regex must not start with --.
|
||||||
// NOTE: stylelint tests the pattern against the property WITHOUT the
|
|
||||||
// leading "--", so the regex must NOT start with --.
|
|
||||||
// Regex: either an exact brand token or a prefixed token:
|
|
||||||
// ^(brand-main|brand-alt)$ — brand exceptions
|
|
||||||
// |^(color|space|size|text|radius|bp|font)- — prefixed
|
|
||||||
"custom-property-pattern": [
|
"custom-property-pattern": [
|
||||||
"^(brand-main|brand-alt)$|^(color|space|size|text|radius|bp|font|duration|ease|z)-",
|
"^(brand-main|brand-alt)$|^(color|space|size|text|radius|bp|font|duration|ease|z)-",
|
||||||
{
|
{
|
||||||
@@ -33,10 +28,8 @@ export default {
|
|||||||
"declaration-no-important": true,
|
"declaration-no-important": true,
|
||||||
// Grouping whitespace for the token file. The standard config puts
|
// Grouping whitespace for the token file. The standard config puts
|
||||||
// "after-custom-property" into `except`, which makes --fix DELETE blank
|
// "after-custom-property" into `except`, which makes --fix DELETE blank
|
||||||
// lines between consecutive custom properties — so color/size token groups
|
// lines between consecutive custom properties, collapsing token groups
|
||||||
// collapse into one wall. Moving it to `ignore` frees blank lines between
|
// into one wall. `ignore` keeps groups readable.
|
||||||
// tokens (groups stay readable); the blank line is still REQUIRED before a
|
|
||||||
// token that follows a regular declaration.
|
|
||||||
"custom-property-empty-line-before": [
|
"custom-property-empty-line-before": [
|
||||||
"always",
|
"always",
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ const base: "" | `/${string}` =
|
|||||||
: (`/${rawBase.replace(/^\/+/, "").replace(/\/+$/, "")}` as `/${string}`);
|
: (`/${rawBase.replace(/^\/+/, "").replace(/\/+$/, "")}` as `/${string}`);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// PUBLIC_* попадают в import.meta.env — флаги окружения вроде GA.
|
// PUBLIC_* lands in import.meta.env: environment flags like GA.
|
||||||
envPrefix: ["VITE_", "PUBLIC_"],
|
envPrefix: ["VITE_", "PUBLIC_"],
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ["@lucide/svelte"],
|
noExternal: ["@lucide/svelte"],
|
||||||
|
|||||||
Reference in New Issue
Block a user