mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
refactor: better typing for tool icons
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+12
-12
@@ -222,18 +222,18 @@ 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-последствиями, не часть обычной задачи |
|
||||
| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
|
||||
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
|
||||
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
|
||||
| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу |
|
||||
| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов |
|
||||
| Мультифайловый результат | `result: "files"` + `zip.ts` |
|
||||
| Задача | Где править |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) |
|
||||
| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен |
|
||||
| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются |
|
||||
| Смена `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/` |
|
||||
| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу |
|
||||
| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов |
|
||||
| Мультифайловый результат | `result: "files"` + `zip.ts` |
|
||||
|
||||
## 8. Рецепт: добавить инструмент
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
|
||||
## Тесты и lint
|
||||
|
||||
- [ ] линтер на deprecated иконки возможен? 'FilePlus2' is deprecated.
|
||||
- [ ] `tseslint.config(...)` deprecated: сигнатура
|
||||
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
|
||||
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user