mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
Compare commits
3
Commits
66b23ec3bf
...
7a61f5a85b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a61f5a85b | ||
|
|
de3636a866 | ||
|
|
0e6a8464af |
+28
-19
@@ -104,13 +104,12 @@
|
|||||||
Чего нет (пробелы закрываются этапами ниже):
|
Чего нет (пробелы закрываются этапами ниже):
|
||||||
|
|
||||||
| Пробел | Где сейчас | Этап |
|
| Пробел | Где сейчас | Этап |
|
||||||
| -------------------------------------------------- | ----------------------------------- | ---- |
|
| -------------------------------------------------- | -------------------------------- | ---- |
|
||||||
| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
|
| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
|
||||||
| `sitemap.xml` | нет | S1 |
|
| `sitemap.xml` | генерируется при сборке (S1b) | — |
|
||||||
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
|
| `robots.txt` без `Sitemap:` | allow-all + `Sitemap:` (S1c) | — |
|
||||||
| `rel=canonical` | нет | S1 |
|
| `rel=canonical` | есть, `Seo.svelte` (S1d) | — |
|
||||||
| OG/Twitter-теги, OG-картинки | нет | S1 |
|
| OG/Twitter-теги | есть; per-tool картинки — S1e | S1 |
|
||||||
| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
|
|
||||||
| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
|
| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
|
||||||
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
|
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
|
||||||
| `llms.txt`, хабы категорий с текстом | нет | S3 |
|
| `llms.txt`, хабы категорий с текстом | нет | S3 |
|
||||||
@@ -211,19 +210,29 @@
|
|||||||
(301) делает сам GitHub Pages при включённом кастомном домене. Делается
|
(301) делает сам GitHub Pages при включённом кастомном домене. Делается
|
||||||
одновременно с S1d (canonical), чтобы Google проиндексировал сразу с
|
одновременно с S1d (canonical), чтобы Google проиндексировал сразу с
|
||||||
правильным доменом.
|
правильным доменом.
|
||||||
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
|
- [x] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs` — **сделано 2026-10-12**:
|
||||||
`build/` из `PAGES` реестра (`web/src/lib/registry`) + статических
|
пишет `sitemap.xml` в `build/` после `vite build` (вызов добавлен в
|
||||||
страниц. Взять адреса у страниц, а не у инструментов: страниц может быть
|
`pnpm build`). Адреса берутся **из собранного HTML**, а не из импорта
|
||||||
несколько на один инструмент, у инструмента адреса нет. Вызов из
|
`PAGES`: список prerender-страниц и есть фактический набор URL, а страницы
|
||||||
`pnpm build` после `vite build` (adapter-static пишет в `build/`,
|
с `noindex` в HTML автоматически исключаются — sitemap не может разойтись
|
||||||
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
|
с тем, что реально задеплоено, и не рекламирует то, что мы просим
|
||||||
опционального `BASE_PATH`.
|
краулеров игнорировать. Проверено: в sitemap попадают `/`, `/list-tools`,
|
||||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
|
`/chains`, но не `/kit`, `/pipeline` и страницы инструментов (пока S1h).
|
||||||
cleanup — вернуть минимальный allow-all.
|
Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME` (тот же домен, что
|
||||||
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
публикует GitHub Pages, без третьей копии литерала); опциональный
|
||||||
актуальному маршруту), OG-теги (title/description/type/url/image),
|
`BASE_PATH` учитывается как в `vite.config.ts`.
|
||||||
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
- [x] **[S1c]** `robots.txt` — **сделано 2026-10-12**: `Sitemap:` добавлен,
|
||||||
для абсолютных URL в canonical и OG:url.
|
allow-all сохранён. **Служебный `/kit` закрыт не через `Disallow:`, а
|
||||||
|
через `noindex` в HTML** (как `/pipeline`): заблокированный краулером, но
|
||||||
|
не помеченный noindex URL Google помечает «проиндексирован, несмотря на
|
||||||
|
блокировку в robots.txt» и оставляет старый сниппет. Побочная выгода: тот
|
||||||
|
же признак автоматически выкидывает `/kit` из sitemap.
|
||||||
|
- [x] **[S1d]** Компонент `Seo.svelte` (kit) — **был сделан до S1b, отмечено
|
||||||
|
2026-10-12**: canonical (абсолютный URL от `PUBLIC_SITE_ORIGIN` +
|
||||||
|
`SITE_URL`, `web/src/lib/seo.ts`), OG (title/description/type/url/image) и
|
||||||
|
`twitter:card`. Подключён на `/`, `/list-tools`, `/chains`,
|
||||||
|
`/tools/[slug]`. Картинка пока общая `og/default.png` — per-tool остаётся
|
||||||
|
за S1e.
|
||||||
|
|
||||||
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
|
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
|
||||||
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
|
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
|
||||||
|
|||||||
@@ -205,19 +205,24 @@ test("split-into-parts-png downloads a zip with named PNG parts", async ({
|
|||||||
await uploadImage(page, landscapePng);
|
await uploadImage(page, landscapePng);
|
||||||
await rangeInput(page, "Columns").fill("3");
|
await rangeInput(page, "Columns").fill("3");
|
||||||
await rangeInput(page, "Rows").fill("2");
|
await rangeInput(page, "Rows").fill("2");
|
||||||
await expect(page.getByText("part-1-3.png")).toBeVisible();
|
// Entries are prefixed with the source name (run-chain glueName), so the
|
||||||
|
// base is derived from the fixture instead of repeated as a literal.
|
||||||
|
const base = landscapePng.name.replace(/\.[^.]+$/, "");
|
||||||
|
await expect(
|
||||||
|
page.getByText(`${base}-part-1-3.png`, { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
const { name, bytes } = await downloadResultBytes(page);
|
const { name, bytes } = await downloadResultBytes(page);
|
||||||
expect(name).toBe("split-into-parts-png.zip");
|
expect(name).toBe("split-into-parts-png.zip");
|
||||||
const entries = unzipSync(bytes);
|
const entries = unzipSync(bytes);
|
||||||
|
|
||||||
expect(Object.keys(entries)).toEqual([
|
expect(Object.keys(entries)).toEqual([
|
||||||
"part-1-1.png",
|
`${base}-part-1-1.png`,
|
||||||
"part-1-2.png",
|
`${base}-part-1-2.png`,
|
||||||
"part-1-3.png",
|
`${base}-part-1-3.png`,
|
||||||
"part-2-1.png",
|
`${base}-part-2-1.png`,
|
||||||
"part-2-2.png",
|
`${base}-part-2-2.png`,
|
||||||
"part-2-3.png",
|
`${base}-part-2-3.png`,
|
||||||
]);
|
]);
|
||||||
for (const bytes of Object.values(entries)) {
|
for (const bytes of Object.values(entries)) {
|
||||||
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
expect(Array.from(bytes.subarray(0, 8))).toEqual([
|
||||||
@@ -296,8 +301,11 @@ test("chain: remove step returns to a single tool", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
await expect(stepTwo).toBeVisible();
|
await expect(stepTwo).toBeVisible();
|
||||||
// Click remove in the Grayscale card specifically: step order depends on
|
// Click remove in the Grayscale card specifically: step order depends on
|
||||||
// render timing and can differ between engines.
|
// render timing and can differ between engines. The card is matched by its
|
||||||
const card = page.getByRole("article").filter({ hasText: "Grayscale PNG" });
|
// own heading, not by hasText on the article - the per-step "Change tool"
|
||||||
|
// popover lists every tool title inside the card, so the text filter would
|
||||||
|
// match all cards at once.
|
||||||
|
const card = page.getByRole("article").filter({ has: stepTwo });
|
||||||
await card.getByLabel("Remove step").click();
|
await card.getByLabel("Remove step").click();
|
||||||
await expect(stepTwo).toHaveCount(0);
|
await expect(stepTwo).toHaveCount(0);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
|
|||||||
@@ -142,13 +142,21 @@ report-only (`warn`), baseline — 0 срабатываний.
|
|||||||
списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности
|
списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности
|
||||||
(кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы —
|
(кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы —
|
||||||
через опцию `allowChars`. Строковые литералы не трогаем — локализованные
|
через опцию `allowChars`. Строковые литералы не трогаем — локализованные
|
||||||
значения легальны в словарях.
|
значения легальны в словарях. В `.svelte` проверяются оба вида комментариев:
|
||||||
|
`<script>` и HTML-комментарии шаблона `<!-- -->` (последние — узлы AST, а не
|
||||||
|
comment-токены, поэтому обходятся отдельным визитором).
|
||||||
- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по
|
- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по
|
||||||
умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15)
|
умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15)
|
||||||
— доля комментарных строк от непустых строк файла; density проверяется только
|
— доля комментарных строк от непустых строк файла; density проверяется только
|
||||||
в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в
|
в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в
|
||||||
маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу
|
маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу
|
||||||
кода, а не к сжатию комментария.
|
кода, а не к сжатию комментария. В `.svelte` считаются все три вида
|
||||||
|
комментариев (и в строках блока, и в плотности): `<script>`, HTML-комментарии
|
||||||
|
шаблона `<!-- -->` — отдельным визитором, как в
|
||||||
|
`conventions/comments-english`, и CSS в `<style>`, который парсится в
|
||||||
|
postcss-AST и берётся хелпером `design-tokens/style-context.js`. Комментарии
|
||||||
|
разных видов не склеиваются в один блок: соседние строки продолжают блок
|
||||||
|
только внутри `<script>`.
|
||||||
|
|
||||||
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
|
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
|
||||||
`*.config.*` живут по тем же конвенциям (dogfooding). Фикстуры и тесты правил
|
`*.config.*` живут по тем же конвенциям (dogfooding). Фикстуры и тесты правил
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
// Tests for the conventions/comment-format rule
|
// Tests for the conventions/comment-format rule
|
||||||
// (web/eslint-plugins/conventions/comment-format.js).
|
// (web/eslint-plugins/conventions/comment-format.js).
|
||||||
//
|
//
|
||||||
// Pure AST check (comment nodes + line counts), standard RuleTester.
|
// Pure AST check (comment nodes + line counts), standard RuleTester. The
|
||||||
|
// svelte pass pins the two comment kinds the token stream never sees: template
|
||||||
|
// `<!-- -->` comments (AST nodes) and CSS in `<style>` (a postcss AST); both
|
||||||
|
// need a svelte parser to exist at all.
|
||||||
import { RuleTester } from "eslint";
|
import { RuleTester } from "eslint";
|
||||||
|
import svelteParser from "svelte-eslint-parser";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
import commentFormat from "../conventions/comment-format.js";
|
import commentFormat from "../conventions/comment-format.js";
|
||||||
@@ -17,6 +21,13 @@ const tester = new RuleTester({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const svelteTester = new RuleTester({
|
||||||
|
languageOptions: {
|
||||||
|
parser: svelteParser,
|
||||||
|
parserOptions: { parser: tseslint.parser },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const longBlock = [
|
const longBlock = [
|
||||||
"// one",
|
"// one",
|
||||||
"// two",
|
"// two",
|
||||||
@@ -96,3 +107,101 @@ tester.run("comment-format", asRuleModule(commentFormat), {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
svelteTester.run("comment-format (svelte)", asRuleModule(commentFormat), {
|
||||||
|
valid: [
|
||||||
|
{
|
||||||
|
code: "<!-- why -->\n<main>Hello</main>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A template comment at the cap is fine, same as in a .ts file.
|
||||||
|
code: "<!--\na\nb\nc\n-->\n<main>Hello</main>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Script and template comments are separate blocks, not one run.
|
||||||
|
code: '<!-- why -->\n<main>Hello</main>\n<script lang="ts">\n// why\n</script>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A CSS comment at the cap is fine, same as a script comment.
|
||||||
|
code: "<style>\n/*\na\nb\nc\n*/\n.x {\n\tcolor: red;\n}\n</style>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Script and <style> comments live in different ASTs: no run across,
|
||||||
|
// so 2 + 5 lines pass where a merged block of 7 would not.
|
||||||
|
code: '<script lang="ts">\n// one\n// two\n</script>\n<style>\n/*\na\nb\nc\n*/\n.x {\n\tcolor: red;\n}\n</style>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
// The template comment the ts pass could never see, over the cap.
|
||||||
|
code: "<!--\na\nb\nc\nd\n-->\n<main>Hello</main>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
errors: [{ messageId: "blockTooLong" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// <style> is a postcss AST, so its comments are invisible to
|
||||||
|
// getAllComments(); the loc must still point at the real line.
|
||||||
|
code: "<style>\n/*\na\nb\nc\nd\n*/\n.x {\n\tcolor: red;\n}\n</style>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
errors: [{ messageId: "blockTooLong", line: 2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A comment nested in markup is collected too (not Program.body).
|
||||||
|
code: "<div>\n\t<!--\n\ta\n\tb\n\tc\n\td\n\t-->\n</div>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
errors: [{ messageId: "blockTooLong" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Template comment lines count toward density: 2 of 4 non-blank.
|
||||||
|
code: "<!-- a -->\n<!-- b -->\n<p>x</p>\n<p>y</p>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxBlockLines: 5, maxRatio: 0.4 }],
|
||||||
|
errors: [{ messageId: "tooDense" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Script and template comments share one density budget.
|
||||||
|
code: '<!-- a -->\n<script lang="ts">\n// b\n</script>\n<p>c</p>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxBlockLines: 5, maxRatio: 0.3 }],
|
||||||
|
errors: [{ messageId: "tooDense" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// CSS lines count toward density too: 2 of 8 non-blank. Consecutive
|
||||||
|
// CSS comments stay separate blocks (each one line, under the cap).
|
||||||
|
code: "<p>x</p>\n<style>\n/* a */\n/* b */\n.x {\n\tcolor: red;\n}\n</style>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxBlockLines: 5, maxRatio: 0.2 }],
|
||||||
|
errors: [{ messageId: "tooDense" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// All three kinds over the cap: one report per block, by position.
|
||||||
|
code: "<!--\na\nb\nc\nd\ne\n-->\n<style>\n/*\nx\ny\nz\nw\nv\n*/\n.x {\n\tcolor: red;\n}\n</style>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxRatio: 1 }],
|
||||||
|
errors: [
|
||||||
|
{ messageId: "blockTooLong", line: 1 },
|
||||||
|
{ messageId: "blockTooLong", line: 9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Both violations in one file produce two reports.
|
||||||
|
code: "<!--\na\nb\nc\nd\n-->\n<p>x</p>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ maxBlockLines: 5, maxRatio: 0.5 }],
|
||||||
|
errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
//
|
//
|
||||||
// Pure AST check over comment nodes with an ASCII whitelist; string literals
|
// Pure AST check over comment nodes with an ASCII whitelist; string literals
|
||||||
// are ignored by construction (they are not comments), which the cases below
|
// are ignored by construction (they are not comments), which the cases below
|
||||||
// pin down.
|
// pin down. .svelte gets its own pass: template `<!-- -->` comments are AST
|
||||||
|
// nodes (SvelteHTMLComment), not comment tokens, so they need a svelte parser.
|
||||||
import { RuleTester } from "eslint";
|
import { RuleTester } from "eslint";
|
||||||
|
import svelteParser from "svelte-eslint-parser";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
import commentsEnglish from "../conventions/comments-english.js";
|
import commentsEnglish from "../conventions/comments-english.js";
|
||||||
import { asRuleModule } from "./helpers.js";
|
import { asRuleModule, svelteComponent } from "./helpers.js";
|
||||||
|
|
||||||
RuleTester.describe = describe;
|
RuleTester.describe = describe;
|
||||||
RuleTester.it = it;
|
RuleTester.it = it;
|
||||||
@@ -19,6 +21,13 @@ const tester = new RuleTester({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const svelteTester = new RuleTester({
|
||||||
|
languageOptions: {
|
||||||
|
parser: svelteParser,
|
||||||
|
parserOptions: { parser: tseslint.parser },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
tester.run("comments-english", asRuleModule(commentsEnglish), {
|
tester.run("comments-english", asRuleModule(commentsEnglish), {
|
||||||
valid: [
|
valid: [
|
||||||
{ code: "// resize the canvas\nconst x = 1;", filename: "a.ts" },
|
{ code: "// resize the canvas\nconst x = 1;", filename: "a.ts" },
|
||||||
@@ -94,3 +103,77 @@ tester.run("comments-english", asRuleModule(commentsEnglish), {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Svelte components: both comment kinds are in scope. `<script>` comments
|
||||||
|
// come in as ordinary comment tokens; template `<!-- -->` comments only exist
|
||||||
|
// as SvelteHTMLComment nodes, which is the case the ts pass cannot reach.
|
||||||
|
svelteTester.run("comments-english (svelte)", asRuleModule(commentsEnglish), {
|
||||||
|
valid: [
|
||||||
|
{
|
||||||
|
code: "<!-- resize the canvas -->\n<main>Hello</main>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: svelteComponent("// resize the canvas"),
|
||||||
|
filename: "Component.svelte",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '<!-- svelte-ignore a11y_missing_attribute -->\n<main lang="en">Hello</main>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// allowChars applies to template comments too.
|
||||||
|
code: "<!-- size 512×512 -->\n<main>Hello</main>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ allowChars: ["×"] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// CSS comments in <style> are checked as well, ASCII passes.
|
||||||
|
code: '<div class="x">hi</div>\n<style>\n/* keep the row height stable */\n.x {\n\tcolor: red;\n}\n</style>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
// The template comment the ts pass could never see.
|
||||||
|
code: "<!-- русский комментарий -->\n<main>Hello</main>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// <style> is a postcss AST, so its comments are invisible to
|
||||||
|
// getAllComments(); the loc must still point at the real line.
|
||||||
|
code: '<div class="x">hi</div>\n<style>\n/* русский комментарий в стилях */\n.x {\n\tcolor: red;\n}\n</style>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
errors: [{ messageId: "nonAscii", line: 3 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// All three comment kinds in one component, reported by position.
|
||||||
|
code: '<!-- раз -->\n<style>\n/* два */\n.x {\n\tcolor: red;\n}\n</style>\n<script lang="ts">\n// три\n</script>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
errors: [
|
||||||
|
{ messageId: "nonAscii", line: 1 },
|
||||||
|
{ messageId: "nonAscii", line: 3 },
|
||||||
|
{ messageId: "nonAscii", line: 9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: svelteComponent("// ресайз холста"),
|
||||||
|
filename: "Component.svelte",
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// One report per comment, script and template alike.
|
||||||
|
code: '<!-- раз -->\n<main>два</main>\n<script lang="ts">\n// три\n</script>',
|
||||||
|
filename: "Component.svelte",
|
||||||
|
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// allowChars does not whitelist other glyphs in the same comment.
|
||||||
|
code: "<!-- 512×512 → done -->\n<main>Hello</main>",
|
||||||
|
filename: "Component.svelte",
|
||||||
|
options: [{ allowChars: ["×"] }],
|
||||||
|
errors: [{ messageId: "nonAscii" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
// Rule: COMMENT FORMAT: bounded blocks and bounded density.
|
// Rule: COMMENT FORMAT: bounded blocks and bounded density.
|
||||||
// maxBlockLines caps one comment block; maxRatio caps the share of comment
|
// maxBlockLines caps one comment block; maxRatio caps the share of comment
|
||||||
// lines in files with minLines+ non-blank lines. Warn-only tripwires:
|
// lines in files with minLines+ non-blank lines. In .svelte, <script>,
|
||||||
// excess means refactor the code, not squeeze the comment.
|
// template <!-- --> and <style> CSS (postcss AST) count alike.
|
||||||
// Details: web/eslint-plugins/README.md.
|
// Details: web/eslint-plugins/README.md.
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStyleNodeLoc,
|
||||||
|
getStyleRoot,
|
||||||
|
} from "../design-tokens/style-context.js";
|
||||||
|
|
||||||
function lineSpan(comment) {
|
function lineSpan(comment) {
|
||||||
return comment.loc.end.line - comment.loc.start.line + 1;
|
return comment.loc.end.line - comment.loc.start.line + 1;
|
||||||
}
|
}
|
||||||
@@ -40,10 +45,42 @@ export default {
|
|||||||
const maxRatio = context.options[0]?.maxRatio ?? 0.15;
|
const maxRatio = context.options[0]?.maxRatio ?? 0.15;
|
||||||
const minLines = context.options[0]?.minLines ?? 0;
|
const minLines = context.options[0]?.minLines ?? 0;
|
||||||
|
|
||||||
|
const htmlComments = [];
|
||||||
|
|
||||||
|
// `<style>` is a postcss AST, not a token stream, so ESLint's comment
|
||||||
|
// tokens never include those comments; they come from the shared helper
|
||||||
|
// the design-tokens rules use (null outside Svelte components).
|
||||||
|
const styleRoot = getStyleRoot(context);
|
||||||
|
const styleNodeLoc = getStyleNodeLoc(context);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Program(node) {
|
// svelte-eslint-parser keeps `<!-- -->` template comments out of the
|
||||||
|
// token stream (they are AST nodes, not comment tokens), so
|
||||||
|
// getAllComments() below never sees them. Collected during traversal
|
||||||
|
// to catch template comments nested inside markup elements too.
|
||||||
|
SvelteHTMLComment(node) {
|
||||||
|
htmlComments.push(node);
|
||||||
|
},
|
||||||
|
"Program:exit"(node) {
|
||||||
const sourceCode = context.sourceCode;
|
const sourceCode = context.sourceCode;
|
||||||
const comments = sourceCode.getAllComments();
|
// `type: "Block"` keeps a CSS comment from merging with the run
|
||||||
|
// above it: only adjacent `//` lines continue one block.
|
||||||
|
const styleComments = [];
|
||||||
|
if (styleRoot && styleNodeLoc) {
|
||||||
|
styleRoot.walkComments((comment) => {
|
||||||
|
const loc = styleNodeLoc(comment);
|
||||||
|
if (loc.start) styleComments.push({ loc, type: "Block" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const comments = [
|
||||||
|
...sourceCode.getAllComments(),
|
||||||
|
...htmlComments,
|
||||||
|
...styleComments,
|
||||||
|
].sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.loc.start.line - b.loc.start.line ||
|
||||||
|
a.loc.start.column - b.loc.start.column,
|
||||||
|
);
|
||||||
if (comments.length === 0) return;
|
if (comments.length === 0) return;
|
||||||
|
|
||||||
const commentLines = new Set();
|
const commentLines = new Set();
|
||||||
@@ -67,7 +104,8 @@ export default {
|
|||||||
commentLines.add(l);
|
commentLines.add(l);
|
||||||
}
|
}
|
||||||
if (i === 0) continue;
|
if (i === 0) continue;
|
||||||
// Adjacent `//` lines form one block; anything else starts a new one.
|
// Adjacent `//` lines form one block; HTML template comments and
|
||||||
|
// everything else start a new one (their own span is the block).
|
||||||
if (
|
if (
|
||||||
comment.type === "Line" &&
|
comment.type === "Line" &&
|
||||||
comments[i - 1].type === "Line" &&
|
comments[i - 1].type === "Line" &&
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
// checked: localized dictionary values legitimately contain other scripts.
|
// checked: localized dictionary values legitimately contain other scripts.
|
||||||
// Details: web/eslint-plugins/README.md.
|
// Details: web/eslint-plugins/README.md.
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStyleNodeLoc,
|
||||||
|
getStyleRoot,
|
||||||
|
} from "../design-tokens/style-context.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
meta: {
|
meta: {
|
||||||
type: "suggestion",
|
type: "suggestion",
|
||||||
@@ -33,19 +38,18 @@ export default {
|
|||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
const allowed = new Set(context.options[0]?.allowChars ?? []);
|
const allowed = new Set(context.options[0]?.allowChars ?? []);
|
||||||
return {
|
|
||||||
Program() {
|
const check = (text, loc) => {
|
||||||
for (const comment of context.sourceCode.getAllComments()) {
|
|
||||||
const offenders = new Set();
|
const offenders = new Set();
|
||||||
for (const match of comment.value.matchAll(
|
for (const match of text.matchAll(
|
||||||
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
|
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
|
||||||
/[^\x09\x0A\x0D\x20-\x7E]/g,
|
/[^\x09\x0A\x0D\x20-\x7E]/g,
|
||||||
)) {
|
)) {
|
||||||
if (!allowed.has(match[0])) offenders.add(match[0]);
|
if (!allowed.has(match[0])) offenders.add(match[0]);
|
||||||
}
|
}
|
||||||
if (offenders.size === 0) continue;
|
if (offenders.size === 0) return;
|
||||||
context.report({
|
context.report({
|
||||||
loc: comment.loc,
|
loc,
|
||||||
messageId: "nonAscii",
|
messageId: "nonAscii",
|
||||||
data: {
|
data: {
|
||||||
chars: [...offenders]
|
chars: [...offenders]
|
||||||
@@ -56,7 +60,34 @@ export default {
|
|||||||
.join(", "),
|
.join(", "),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// `<style>` is a postcss AST, not a token stream, so ESLint's comment
|
||||||
|
// tokens never include those comments; they come from the shared helper
|
||||||
|
// the design-tokens rules use (null outside Svelte components).
|
||||||
|
const styleRoot = getStyleRoot(context);
|
||||||
|
const styleNodeLoc = getStyleNodeLoc(context);
|
||||||
|
const htmlComments = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
// svelte-eslint-parser keeps `<!-- -->` template comments out of the
|
||||||
|
// token stream (they are AST nodes, not comment tokens), so
|
||||||
|
// getAllComments() below never sees them. Collected during traversal
|
||||||
|
// to catch template comments nested inside markup elements too.
|
||||||
|
SvelteHTMLComment(node) {
|
||||||
|
htmlComments.push(node);
|
||||||
|
},
|
||||||
|
"Program:exit"() {
|
||||||
|
for (const comment of context.sourceCode.getAllComments()) {
|
||||||
|
check(comment.value, comment.loc);
|
||||||
}
|
}
|
||||||
|
for (const node of htmlComments) {
|
||||||
|
check(node.value, node.loc);
|
||||||
|
}
|
||||||
|
if (!styleRoot || !styleNodeLoc) return;
|
||||||
|
styleRoot.walkComments((comment) => {
|
||||||
|
check(comment.text ?? "", styleNodeLoc(comment));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build && node scripts/gen-sitemap.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Writes build/sitemap.xml right after `vite build` (adapter-static already
|
||||||
|
// emitted the HTML there). The URL set is read back from the build output
|
||||||
|
// instead of the registry: prerendered pages are exactly the pages that exist,
|
||||||
|
// and a page whose HTML carries `noindex` is skipped, so the sitemap can never
|
||||||
|
// drift from what was shipped or advertise a page we ask crawlers to ignore.
|
||||||
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, relative, sep } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const BUILD_DIR = fileURLToPath(new URL("../build/", import.meta.url));
|
||||||
|
|
||||||
|
function siteOrigin() {
|
||||||
|
const fromEnv = (process.env.PUBLIC_SITE_ORIGIN ?? "").trim();
|
||||||
|
if (fromEnv !== "") return fromEnv.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
// Local builds run without CI env vars; `static/CNAME` is the domain GitHub
|
||||||
|
// Pages publishes under, so it is the same truth as the deploy-time env.
|
||||||
|
const cnamePath = fileURLToPath(new URL("../static/CNAME", import.meta.url));
|
||||||
|
if (!existsSync(cnamePath)) {
|
||||||
|
throw new Error(
|
||||||
|
"gen-sitemap: no site origin — set PUBLIC_SITE_ORIGIN or add web/static/CNAME",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cname = readFileSync(cnamePath, "utf8").trim();
|
||||||
|
if (cname === "") {
|
||||||
|
throw new Error("gen-sitemap: web/static/CNAME is empty");
|
||||||
|
}
|
||||||
|
return `https://${cname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the `base` normalization in vite.config.ts. */
|
||||||
|
const rawBase = (process.env.BASE_PATH ?? "").trim();
|
||||||
|
const base =
|
||||||
|
rawBase === "" || rawBase === "/"
|
||||||
|
? ""
|
||||||
|
: `/${rawBase.replace(/^\/+/, "").replace(/\/+$/, "")}`;
|
||||||
|
|
||||||
|
function* htmlFiles(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) yield* htmlFiles(full);
|
||||||
|
else if (entry.name.endsWith(".html")) yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* adapter-static names prerendered pages `<route>.html` (`tools/crop-png.html`)
|
||||||
|
* because `trailingSlash` is "never"; the `index.html` form covers a future
|
||||||
|
* switch. GitHub Pages serves the flat file at the extension-less URL.
|
||||||
|
*/
|
||||||
|
function routePath(file) {
|
||||||
|
const rel = relative(BUILD_DIR, file).split(sep).join("/");
|
||||||
|
if (rel === "404.html") return null;
|
||||||
|
if (rel === "index.html") return "/";
|
||||||
|
if (rel.endsWith("/index.html"))
|
||||||
|
return `/${rel.slice(0, -"index.html".length)}`;
|
||||||
|
return `/${rel.slice(0, -".html".length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return value.replace(/&/g, "&").replace(/</g, "<");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(BUILD_DIR)) {
|
||||||
|
throw new Error("gen-sitemap: build/ is missing — run `vite build` first");
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = siteOrigin();
|
||||||
|
const locs = [];
|
||||||
|
|
||||||
|
for (const file of htmlFiles(BUILD_DIR)) {
|
||||||
|
const html = readFileSync(file, "utf8");
|
||||||
|
if (/<meta\s+name="robots"\s+content="[^"]*noindex/.test(html)) continue;
|
||||||
|
|
||||||
|
const route = routePath(file);
|
||||||
|
if (route === null) continue;
|
||||||
|
|
||||||
|
locs.push(`${origin}${base}${route}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locs.length === 0) {
|
||||||
|
throw new Error("gen-sitemap: no indexable pages found in build/");
|
||||||
|
}
|
||||||
|
|
||||||
|
locs.sort();
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...locs.map((loc) => `\t<url><loc>${escapeXml(loc)}</loc></url>`),
|
||||||
|
"</urlset>",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
writeFileSync(join(BUILD_DIR, "sitemap.xml"), xml);
|
||||||
|
console.log(`sitemap.xml written: ${locs.length} urls (origin ${origin})`);
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-m);
|
gap: var(--space-m);
|
||||||
/* Резерв под бейдж: строка не прыгает, когда заметка появляется. */
|
/* Reserve for the badge: the row does not jump when the note appears. */
|
||||||
min-height: var(--space-xxl);
|
min-height: var(--space-xxl);
|
||||||
margin-bottom: var(--space-m);
|
margin-bottom: var(--space-m);
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-m);
|
gap: var(--space-m);
|
||||||
}
|
}
|
||||||
/* Место под заметки результата: бейджи-предупреждения. */
|
/* Slot for result notes: warning badges. */
|
||||||
.tile-note {
|
.tile-note {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -177,12 +177,12 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
/* Native popover: top layer + light dismiss + Escape из коробки;
|
/* Native popover: top layer + light dismiss + Escape out of the box;
|
||||||
позиционирование — trackPopover (anchor() поддержан не везде). */
|
positioning is trackPopover (anchor() is not supported everywhere). */
|
||||||
position: fixed;
|
position: fixed;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
translate: 0 var(--space-s);
|
translate: 0 var(--space-s);
|
||||||
/* Ширина под строку качества: числовое поле + степперы + сброс. */
|
/* Width for the quality row: number field + steppers + reset. */
|
||||||
min-width: calc(var(--space-xxxl) * 9);
|
min-width: calc(var(--space-xxxl) * 9);
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
padding: var(--space-m);
|
padding: var(--space-m);
|
||||||
@@ -190,8 +190,8 @@
|
|||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
/* display задаётся только в открытом состоянии: авторский display
|
/* display applies only while open: the author display overrides the
|
||||||
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
UA rule [popover]:not(:popover-open) { display: none }. */
|
||||||
.menu:popover-open {
|
.menu:popover-open {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,8 +128,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
/* Вложен в PreviewTile.canvas: без растяжения сжимался по контенту и
|
/* Nested in PreviewTile.canvas: without stretch it shrank to its
|
||||||
ломал parts-grid в одну колонку. */
|
content and collapsed parts-grid into a single column. */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
min-height: var(--size-tile-canvas-min);
|
min-height: var(--size-tile-canvas-min);
|
||||||
|
|||||||
@@ -105,8 +105,8 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
/* Native popover: top layer + light dismiss + Escape из коробки;
|
/* Native popover: top layer + light dismiss + Escape out of the box;
|
||||||
позиционирование — trackPopover (anchor() поддержан не везде). */
|
positioning is trackPopover (anchor() is not supported everywhere). */
|
||||||
position: fixed;
|
position: fixed;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
translate: 0 var(--space-s);
|
translate: 0 var(--space-s);
|
||||||
@@ -119,8 +119,8 @@
|
|||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
/* display задаётся только в открытом состоянии: авторский display
|
/* display applies only while open: the author display overrides the
|
||||||
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
UA rule [popover]:not(:popover-open) { display: none }. */
|
||||||
.menu:popover-open {
|
.menu:popover-open {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
/* Мини-размер цели в 24px: попадание пальцем без промаха. */
|
/* Mini target size at 24px: tap without missing. */
|
||||||
width: calc(var(--space-xxxl) * 0.66);
|
width: calc(var(--space-xxxl) * 0.66);
|
||||||
height: calc(var(--space-xxxl) * 0.66);
|
height: calc(var(--space-xxxl) * 0.66);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -78,9 +78,9 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border-color: var(--color-main);
|
border-color: var(--color-main);
|
||||||
}
|
}
|
||||||
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
|
/* The native popover lives in the top layer: light dismiss, Escape and
|
||||||
поверх всего — из коробки. Позиционирование — trackPopover: anchor()
|
stacking above everything come for free. Positioning is trackPopover:
|
||||||
поддержан не во всех браузерах. */
|
anchor() is not supported in every browser. */
|
||||||
.popover {
|
.popover {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="showcase">
|
<div class="showcase">
|
||||||
<header class="showcase-head">
|
<header class="showcase-head">
|
||||||
<span class="showcase-eyebrow">KIT SHOWCASE</span>
|
<span class="showcase-eyebrow">KIT SHOWCASE</span>
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
# allow crawling everything by default
|
# allow crawling everything by default
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
Disallow:
|
||||||
|
|
||||||
|
# Service pages (/kit) are excluded from the index with a `noindex` meta tag,
|
||||||
|
# not with Disallow: a blocked-but-not-noindexed URL shows up in Search Console
|
||||||
|
# as an error and keeps its old indexed snippet.
|
||||||
|
|
||||||
|
Sitemap: https://easy-image-tools.com/sitemap.xml
|
||||||
Reference in New Issue
Block a user