refactor: better typing for tool icons

This commit is contained in:
2026-10-10 11:47:31 +05:00
parent 8b2172c39a
commit 2ddb348e54
20 changed files with 94 additions and 62 deletions
+12 -5
View File
@@ -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<PageSlug, IconComponent>` —
переименование или удаление страницы теперь ломает компиляцию, а не падает
молча. Чтение с внешней строкой — через аксессор `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)
+2 -2
View File
@@ -223,11 +223,11 @@ i18n-подписи → тест в `registry-schema.test.ts`.
## 7. Точки расширения
| Задача | Где править |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) |
| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен |
| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются |
| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `tool-icons.ts` + `docs/tools-map.md` + хардкод slug в тестах и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи |
| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `docs/tools-map.md` + хардкод slug в тестах и e2e; иконки править не нужно — `TOOL_ICONS` типизирован `Record<PageSlug, _>` и ломает компиляцию; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи |
| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
+1
View File
@@ -194,6 +194,7 @@
## Тесты и lint
- [ ] линтер на deprecated иконки возможен? 'FilePlus2' is deprecated.
- [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
+1 -1
View File
@@ -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<string>(PAGES.map((page) => page.slug));
for (const [locale, dict] of [
["ru", ru],
["en", en],
+7 -2
View File
@@ -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);
+2 -2
View File
@@ -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[];
+2 -2
View File
@@ -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[];
+2 -2
View File
@@ -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[];
+2 -2
View File
@@ -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[];
+2 -2
View File
@@ -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[];
+2 -2
View File
@@ -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[];
+2 -2
View File
@@ -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[];
+2 -2
View File
@@ -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[];
+3 -1
View File
@@ -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<string>(
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([]);
});
+1 -1
View File
@@ -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<string>(PAGES.map((page) => page.slug));
it("parses a non-empty slug list (parser did not silently break)", () => {
expect(mentioned.length).toBeGreaterThan(0);
+5 -5
View File
@@ -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/<slug>`, 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<P>(ctx: ToolContext<P>): PixelImage {
+18
View File
@@ -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<string>(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([]);
});
});
+12 -13
View File
@@ -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<string, typeof AppWindow> = {
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<PageSlug, IconComponent> = {
"resize-png": Scaling,
"crop-png": Crop,
"rotate-png": RotateCw,
@@ -61,12 +63,6 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
"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<string, typeof AppWindow> = {
"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<string, typeof AppWindow> = {
"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<string, typeof AppWindow> = {
"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<PageSlug, _> above.
export function toolIcon(slug: string): IconComponent {
return TOOL_ICONS[slug as PageSlug];
}
+3 -3
View File
@@ -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<string>("all");
@@ -52,7 +52,7 @@
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
icon={toolIcon(page.slug)}
/>
{/each}
</CatalogGroup>
@@ -65,7 +65,7 @@
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
icon={toolIcon(page.slug)}
/>
{/each}
</CatalogGroup>
+3 -3
View File
@@ -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<string>("all");
@@ -56,7 +56,7 @@
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
icon={toolIcon(page.slug)}
/>
{/each}
</CatalogGroup>
@@ -69,7 +69,7 @@
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
icon={toolIcon(page.slug)}
/>
{/each}
</CatalogGroup>