From 2ddb348e54fe681594abe4abe44d3e435277d5c0 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 10 Oct 2026 11:47:31 +0500 Subject: [PATCH] refactor: better typing for tool icons --- docs/architecture-audit-2026-10.md | 17 ++++++++++----- docs/architecture.md | 24 ++++++++++----------- docs/backlog.md | 1 + web/src/lib/i18n/tools-i18n.test.ts | 2 +- web/src/lib/registry/index.ts | 9 ++++++-- web/src/lib/registry/pages/alpha.ts | 4 ++-- web/src/lib/registry/pages/analyze.ts | 4 ++-- web/src/lib/registry/pages/color.ts | 4 ++-- web/src/lib/registry/pages/convert.ts | 4 ++-- web/src/lib/registry/pages/filters.ts | 4 ++-- web/src/lib/registry/pages/generate.ts | 4 ++-- web/src/lib/registry/pages/geometry.ts | 4 ++-- web/src/lib/registry/pages/text.ts | 4 ++-- web/src/lib/registry/registry.test.ts | 4 +++- web/src/lib/registry/tools-map-doc.test.ts | 2 +- web/src/lib/registry/types.ts | 10 ++++----- web/src/lib/tool-icons.test.ts | 18 ++++++++++++++++ web/src/lib/tool-icons.ts | 25 +++++++++++----------- web/src/routes/+page.svelte | 6 +++--- web/src/routes/list-tools/+page.svelte | 6 +++--- 20 files changed, 94 insertions(+), 62 deletions(-) create mode 100644 web/src/lib/tool-icons.test.ts diff --git a/docs/architecture-audit-2026-10.md b/docs/architecture-audit-2026-10.md index 63358c2..7860097 100644 --- a/docs/architecture-audit-2026-10.md +++ b/docs/architecture-audit-2026-10.md @@ -180,12 +180,19 @@ round-семантика `clampByte` и trunc-семантика `clampInt` со - `executor.ts` `as unknown as WorkerLike` оставлен намеренно: несовместимость `onerror` (ErrorEvent vs Event) в типах lib.dom; добавлен комментарий-причина. -### L5. Хардкод slug-ов вне registry +### L5. Хардкод slug-ов вне registry — СДЕЛАНО (2026-10) -`tool-icons.ts:54-142` — карта slug→icon без типизации по slug-union -(переименование slug ломает иконки молча); `kit/+page.svelte:40` — захардкожены -`["linear-gradient-png","resize-png","quantize-png"]` (kit — витрина, не -production). +`Page` стал readonly, группы `registry/pages/*` — +`as const satisfies readonly Page[]`; в `registry/index.ts` добавлен union +`PageSlug`. `TOOL_ICONS` типизирован `Record` — +переименование или удаление страницы теперь ломает компиляцию, а не падает +молча. Чтение с внешней строкой — через аксессор `toolIcon(slug)`. Попутно +найдены и удалены 9 мёртвых ключей иконок (страницы уже не существуют: +`png-info`, `jpg-to-png`, `webp-to-png`, `gif-to-png`, `bmp-to-png`, +`ico-to-png`, `compress-png`, `reduce-to-size-png`, `watermark-image-png`). +Добавлен runtime-тест `web/src/lib/tool-icons.test.ts` (оба направления: сироты +и страницы без иконки). Хардкод в `kit/+page.svelte` оставлен — витрина, не +production. ### L6. Дублирование CSS-значения превью-тайла — СДЕЛАНО (2026-10) diff --git a/docs/architecture.md b/docs/architecture.md index bdf8e97..6776986 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -222,18 +222,18 @@ i18n-подписи → тест в `registry-schema.test.ts`. ## 7. Точки расширения -| Задача | Где править | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Новый инструмент | `registry/tools/.ts` + страница в `registry/pages/.ts` + i18n + тесты (рецепт ниже) | -| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен | -| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются | -| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `tool-icons.ts` + `docs/tools-map.md` + хардкод slug в тестах и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи | -| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) | -| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге | -| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` | -| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу | -| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов | -| Мультифайловый результат | `result: "files"` + `zip.ts` | +| Задача | Где править | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Новый инструмент | `registry/tools/.ts` + страница в `registry/pages/.ts` + i18n + тесты (рецепт ниже) | +| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен | +| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются | +| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `docs/tools-map.md` + хардкод slug в тестах и e2e; иконки править не нужно — `TOOL_ICONS` типизирован `Record` и ломает компиляцию; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи | +| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) | +| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге | +| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` | +| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу | +| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов | +| Мультифайловый результат | `result: "files"` + `zip.ts` | ## 8. Рецепт: добавить инструмент diff --git a/docs/backlog.md b/docs/backlog.md index f6033a9..a37c446 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -194,6 +194,7 @@ ## Тесты и lint +- [ ] линтер на deprecated иконки возможен? 'FilePlus2' is deprecated. - [ ] `tseslint.config(...)` deprecated: сигнатура `(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint diff --git a/web/src/lib/i18n/tools-i18n.test.ts b/web/src/lib/i18n/tools-i18n.test.ts index 519d636..07219f7 100644 --- a/web/src/lib/i18n/tools-i18n.test.ts +++ b/web/src/lib/i18n/tools-i18n.test.ts @@ -76,7 +76,7 @@ describe("dictionary completeness for the new registry", () => { }); it("the pages section has no extra pages", () => { - const slugs = new Set(PAGES.map((page) => page.slug)); + const slugs = new Set(PAGES.map((page) => page.slug)); for (const [locale, dict] of [ ["ru", ru], ["en", en], diff --git a/web/src/lib/registry/index.ts b/web/src/lib/registry/index.ts index c56c2c4..13f4240 100644 --- a/web/src/lib/registry/index.ts +++ b/web/src/lib/registry/index.ts @@ -50,7 +50,9 @@ export const TOOLS: Tool[] = [ ...textTools, ] as unknown as Tool[]; -export const PAGES: Page[] = [ +// Not annotated as Page[] on purpose: the literal slug types must survive +// for PageSlug below; every consumer treats PAGES as a readonly Page list. +export const PAGES = [ ...geometryPages, ...alphaPages, ...convertPages, @@ -59,7 +61,10 @@ export const PAGES: Page[] = [ ...colorPages, ...generatePages, ...textPages, -] as unknown as Page[]; +]; + +/** Union of existing page slugs (groups use `satisfies Page[]`, so literals survive). */ +export type PageSlug = (typeof PAGES)[number]["slug"]; export function getTool(id: string): Tool | undefined { return TOOLS.find((tool) => tool.id === id); diff --git a/web/src/lib/registry/pages/alpha.ts b/web/src/lib/registry/pages/alpha.ts index b5da19b..dd6015e 100644 --- a/web/src/lib/registry/pages/alpha.ts +++ b/web/src/lib/registry/pages/alpha.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const alphaPages: Page[] = [ +export const alphaPages = [ { slug: "add-stroke-png", title: "Outline PNG", @@ -157,4 +157,4 @@ export const alphaPages: Page[] = [ category: "alpha", steps: [{ id: "close-holes" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/pages/analyze.ts b/web/src/lib/registry/pages/analyze.ts index 7b05a7a..d6ac3c3 100644 --- a/web/src/lib/registry/pages/analyze.ts +++ b/web/src/lib/registry/pages/analyze.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const analyzePages: Page[] = [ +export const analyzePages = [ { slug: "extract-color-from-png", title: "Extract Color from PNG", @@ -93,4 +93,4 @@ export const analyzePages: Page[] = [ category: "analyze", steps: [{ id: "orientation" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/pages/color.ts b/web/src/lib/registry/pages/color.ts index e88a609..408aaa4 100644 --- a/web/src/lib/registry/pages/color.ts +++ b/web/src/lib/registry/pages/color.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const colorPages: Page[] = [ +export const colorPages = [ { slug: "two-colors-png", title: "Two colors PNG", @@ -199,4 +199,4 @@ export const colorPages: Page[] = [ category: "color", steps: [{ id: "to-lab" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/pages/convert.ts b/web/src/lib/registry/pages/convert.ts index aa46fc0..0de5ac2 100644 --- a/web/src/lib/registry/pages/convert.ts +++ b/web/src/lib/registry/pages/convert.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const convertPages: Page[] = [ +export const convertPages = [ { slug: "convert-png-to-jpg", title: "Convert PNG to JPG", @@ -112,4 +112,4 @@ export const convertPages: Page[] = [ category: "convert", steps: [{ id: "from-svg" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/pages/filters.ts b/web/src/lib/registry/pages/filters.ts index 3cc05a7..3e9643c 100644 --- a/web/src/lib/registry/pages/filters.ts +++ b/web/src/lib/registry/pages/filters.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const filtersPages: Page[] = [ +export const filtersPages = [ { slug: "blur-png", title: "Blur PNG", @@ -65,4 +65,4 @@ export const filtersPages: Page[] = [ category: "filters", steps: [{ id: "jpeg-artifacts" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/pages/generate.ts b/web/src/lib/registry/pages/generate.ts index 233a338..d6fb6bb 100644 --- a/web/src/lib/registry/pages/generate.ts +++ b/web/src/lib/registry/pages/generate.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const generatePages: Page[] = [ +export const generatePages = [ { slug: "create-empty-png", title: "Create empty PNG", @@ -164,4 +164,4 @@ export const generatePages: Page[] = [ category: "generate", steps: [{ id: "from-text" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/pages/geometry.ts b/web/src/lib/registry/pages/geometry.ts index 5238321..a827d47 100644 --- a/web/src/lib/registry/pages/geometry.ts +++ b/web/src/lib/registry/pages/geometry.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const geometryPages: Page[] = [ +export const geometryPages = [ { slug: "add-border-png", title: "Add border to PNG", @@ -149,4 +149,4 @@ export const geometryPages: Page[] = [ category: "geometry", steps: [{ id: "shift" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/pages/text.ts b/web/src/lib/registry/pages/text.ts index 2bb867c..78708dc 100644 --- a/web/src/lib/registry/pages/text.ts +++ b/web/src/lib/registry/pages/text.ts @@ -1,6 +1,6 @@ import type { Page } from "../types"; -export const textPages: Page[] = [ +export const textPages = [ { slug: "add-text-png", title: "Add text to PNG", @@ -25,4 +25,4 @@ export const textPages: Page[] = [ category: "text", steps: [{ id: "watermark-tile" }], }, -]; +] as const satisfies readonly Page[]; diff --git a/web/src/lib/registry/registry.test.ts b/web/src/lib/registry/registry.test.ts index 013cfdb..7a7ef25 100644 --- a/web/src/lib/registry/registry.test.ts +++ b/web/src/lib/registry/registry.test.ts @@ -48,7 +48,9 @@ describe("registry-new (migrated tools)", () => { }); it("every tool is used by at least one page", () => { - const used = new Set(PAGES.flatMap((page) => page.steps.map((s) => s.id))); + const used = new Set( + PAGES.flatMap((page) => page.steps.map((s) => s.id)), + ); const orphans = TOOLS.map((tool) => tool.id).filter((id) => !used.has(id)); expect(orphans, `tools without pages:\n${orphans.join("\n")}`).toEqual([]); }); diff --git a/web/src/lib/registry/tools-map-doc.test.ts b/web/src/lib/registry/tools-map-doc.test.ts index 0c2898c..96b0dbf 100644 --- a/web/src/lib/registry/tools-map-doc.test.ts +++ b/web/src/lib/registry/tools-map-doc.test.ts @@ -104,7 +104,7 @@ describe("docs/tools-map.md vs registry pages", () => { const markdown = readFileSync(MAP_PATH, "utf8"); const mentioned = mentionedIds(markdown); const planned = plannedIds(markdown); - const registrySlugs = new Set(PAGES.map((page) => page.slug)); + const registrySlugs = new Set(PAGES.map((page) => page.slug)); it("parses a non-empty slug list (parser did not silently break)", () => { expect(mentioned.length).toBeGreaterThan(0); diff --git a/web/src/lib/registry/types.ts b/web/src/lib/registry/types.ts index 3a9e574..84a7471 100644 --- a/web/src/lib/registry/types.ts +++ b/web/src/lib/registry/types.ts @@ -80,12 +80,12 @@ export type PageStep = { /** Page: unique address, strings, and the ordered tool list in `steps` (a page may be a ready-made chain run by `SchemaToolView`). */ export type Page = { /** Address `/tools/`, downloaded file name, and icon key. */ - slug: string; + readonly slug: string; /** EN string; the translation lives in the dictionary by slug. */ - title: string; - description: string; - category: CategoryId; - steps: PageStep[]; + readonly title: string; + readonly description: string; + readonly category: CategoryId; + readonly steps: readonly PageStep[]; }; export function requireSource

(ctx: ToolContext

): PixelImage { diff --git a/web/src/lib/tool-icons.test.ts b/web/src/lib/tool-icons.test.ts new file mode 100644 index 0000000..c05cb0e --- /dev/null +++ b/web/src/lib/tool-icons.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { PAGES } from "./registry"; +import { TOOL_ICONS } from "./tool-icons"; + +describe("tool-icons", () => { + it("every icon key is an existing page slug", () => { + const slugs = new Set(PAGES.map((page) => page.slug)); + const orphans = Object.keys(TOOL_ICONS).filter((key) => !slugs.has(key)); + expect(orphans).toEqual([]); + }); + + it("every page has an icon", () => { + const missing = PAGES.map((page) => page.slug).filter( + (slug) => !(slug in TOOL_ICONS), + ); + expect(missing).toEqual([]); + }); +}); diff --git a/web/src/lib/tool-icons.ts b/web/src/lib/tool-icons.ts index 7179d20..594342d 100644 --- a/web/src/lib/tool-icons.ts +++ b/web/src/lib/tool-icons.ts @@ -1,5 +1,4 @@ import { - AppWindow, Binary, Blend, CalendarDays, @@ -15,7 +14,6 @@ import { FileImage, FileOutput, FilePlus2, - Film, FlipHorizontal2, Focus, Frame, @@ -27,7 +25,6 @@ import { Layers, Link2, Maximize2, - Minimize2, Moon, PaintBucket, Palette, @@ -49,8 +46,13 @@ import { ZoomIn, ZoomOut, } from "@lucide/svelte"; +import type { Component } from "svelte"; +import type { PageSlug } from "./registry"; -export const TOOL_ICONS: Record = { +type IconComponent = Component<{ size?: number; class?: string }>; + +// Keyed by page slug: a renamed/missing page is a compile error here. +export const TOOL_ICONS: Record = { "resize-png": Scaling, "crop-png": Crop, "rotate-png": RotateCw, @@ -61,12 +63,6 @@ export const TOOL_ICONS: Record = { "convert-png-to-jpg": FileImage, "convert-png-to-webp": FileImage, "remove-color-from-png": Scissors, - "png-info": Info, - "jpg-to-png": FileImage, - "webp-to-png": FileImage, - "gif-to-png": Film, - "bmp-to-png": FileImage, - "ico-to-png": AppWindow, "png-to-bmp": FileOutput, "png-to-base64": Binary, "base64-to-png": ClipboardPaste, @@ -134,8 +130,6 @@ export const TOOL_ICONS: Record = { "change-aspect-ratio-png": Scaling, "swap-orientation-png": RotateCw, "symmetric-copy-png": FlipHorizontal2, - "compress-png": Minimize2, - "reduce-to-size-png": Scaling, "png-file-size": Info, "png-to-bytes": Binary, "bytes-to-png": Binary, @@ -153,7 +147,6 @@ export const TOOL_ICONS: Record = { "star-mask-png": Star, "wavy-mask-png": WavesHorizontal, "watermark-tile-png": Stamp, - "watermark-image-png": FileImage, "color-wheel-png": Rainbow, "complementary-png": Contrast, "triadic-png": Hash, @@ -183,3 +176,9 @@ export const TOOL_ICONS: Record = { "tint-png": Droplet, "svg-to-png": FileImage, }; + +// Slugs come from the registry at runtime (Page.slug is string there), so the +// lookup widens; completeness is guaranteed by the Record above. +export function toolIcon(slug: string): IconComponent { + return TOOL_ICONS[slug as PageSlug]; +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 8362a9c..1b75330 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -13,7 +13,7 @@ } from "$lib/i18n/schema-tool-strings"; import { t } from "$lib/i18n/t"; import { PAGES } from "$lib/registry"; - import { TOOL_ICONS } from "$lib/tool-icons"; + import { toolIcon } from "$lib/tool-icons"; let query = $state(""); let category = $state("all"); @@ -52,7 +52,7 @@ slug={page.slug} description={pageDescription(page)} index={i + 1} - icon={TOOL_ICONS[page.slug]} + icon={toolIcon(page.slug)} /> {/each} @@ -65,7 +65,7 @@ slug={page.slug} description={pageDescription(page)} index={i + 1} - icon={TOOL_ICONS[page.slug]} + icon={toolIcon(page.slug)} /> {/each} diff --git a/web/src/routes/list-tools/+page.svelte b/web/src/routes/list-tools/+page.svelte index 9bfd823..d910cac 100644 --- a/web/src/routes/list-tools/+page.svelte +++ b/web/src/routes/list-tools/+page.svelte @@ -13,7 +13,7 @@ } from "$lib/i18n/schema-tool-strings"; import { t } from "$lib/i18n/t"; import { PAGES } from "$lib/registry"; - import { TOOL_ICONS } from "$lib/tool-icons"; + import { toolIcon } from "$lib/tool-icons"; let query = $state(""); let category = $state("all"); @@ -56,7 +56,7 @@ slug={page.slug} description={pageDescription(page)} index={i + 1} - icon={TOOL_ICONS[page.slug]} + icon={toolIcon(page.slug)} /> {/each} @@ -69,7 +69,7 @@ slug={page.slug} description={pageDescription(page)} index={i + 1} - icon={TOOL_ICONS[page.slug]} + icon={toolIcon(page.slug)} /> {/each}