Files
easy-png-tools2/docs/architecture.md
T

287 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
> Статус: **active**. Описание текущего кода: границы модулей, поток данных от
> реестра до скачивания, точки расширения. Канонический источник истины — сам
> код; этот документ объясняет, **куда смотреть и что с чем связано**, и не
> дублирует сигнатуры.
## 1. Слои
```txt
web/src/lib/
registry/ # источник истины: инструменты и страницы
registry/tools/ # Tool: реализации (id), без адресов и текстов
registry/pages/ # Page: адрес, тексты, категория, список шагов
registry-schema.ts # декларативная схема параметров + санитизация дефолтов
catalog.ts # группировка страниц и счётчики для /list-tools
categories.ts # id/названия категорий
tool-icons.ts # slug страницы → иконка
core/ # операции над ImageData; без DOM, кроме io.ts/domText.ts
executor/ # единственная точка исполнения инструментов
i18n/ # словари ru/en, t(), состояние локали
components/ # UI по слоям: ui/ примитивы, layout/ каркас,
# catalog/ каталог, schema/ view инструмента,
# fields/ поля (+ fields/schema/ контролы схемы),
# display/ типографика и карточки, feedback/ состояния
zip.ts # сборка мультифайлового результата (1 → many)
theme.svelte.ts # light/dark + localStorage
web/src/routes/
+page.svelte # главная
list-tools/ # каталог
tools/[slug]/ # страница инструмента (адрес = slug страницы)
kit/ # витрина компонентов (production, но исключена из i18n-гейта)
```
Правило зависимостей: `components/**` и `routes/**` знают про `registry/**`,
`executor/**` и `core/**`; `core/**` не знает ни о ком; `registry/**` описывает
данные и знает только `core/**` и `registry-schema.ts`. Нарушение этого порядка
— повод не заводить импорт, а инвертировать зависимость.
Исключение из «core без DOM» — `core/io.ts` (декодирование файла, кодирование,
скачивание) и `core/domText.ts` (canvas-рендер текста и эмодзи). Они трогают
`document`, `createImageBitmap` и `HTMLCanvasElement`, поэтому не работают в
node-Vitest и исключены из coverage-гейта (`docs/quality-gates.md`, раздел
Coverage). Всё остальное в `core/**` — чистые функции над `ImageData`.
### Чего в коде нет
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
скачала результат. Отсутствуют:
- **Пайплайн / цепочки шагов** — реализованы: модуль шагов
`web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps),
именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage
`chains:named`). Страница инструмента всегда открывается чистой; при первом
структурном изменении (добавлен шаг, заменён первый инструмент) цепочка
автоматически становится именованной (имя — дата/время) и маршрут бесшовно
меняется на `/pipeline?id=<id>` (передача исходника через handoff-стор,
replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/
создать). Легаси-автосейв `pipeline:<slug>` снят без миграции (ключи просто
перестают читаться). Экспортный JSON-формат не устоялся и может меняться до
фазы 11 `docs/roadmap.md`.
- **Разделения страниц, которые делят один инструмент** — сводит все
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
на практике не ссылается ни одна.
Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по
пайплайну — `docs/roadmap.md`.
## 2. Поток данных
```txt
registry/{tools,pages}/*.ts
│
├─► catalog.ts ──► routes/list-tools (каталог страниц, поиск, фильтры)
│
└─► routes/tools/[slug]/+page.svelte
│ getPageBySlug(slug) → Page, getTool(steps[0].id) → Tool
▼
SchemaToolView.svelte оркестратор страницы
│ schema = tool.schema, тексты и имя файла — от page
├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол)
├─► SchemaSourceTile/SchemaTextSource.svelte вход
└─► SchemaPreview + schema-preview-model.ts вид превью
│ buildSchemaPreviewModel(input) → { sourceValue, resultValue, formatValue, hasResult }
▼
executor.execute(ctx) единственная точка исполнения
│ сериализация параметров → worker → worker-handler → tool.run(ctx)
▼
core/* пиксели
│
▼
SchemaResultTile / SchemaTextResult + SchemaActions (download/copy)
│ кодирование по Tool.output (mime/ext/quality)
└─► zip.ts, если result === "files"
```
Ключевые решения:
- **Реестр — единственный источник истины.** Страница и форма строятся из
записей; новый инструмент = новая запись `Tool` + функция `run` и новая
страница `Page` на него.
- **Исполнение идёт только через `executor.execute`.** Компоненты не зовут
`tool.run` напрямую — иначе нельзя перенести тяжёлое в worker/wasm без правки
вызывающего кода.
- **Форма параметров не знает про инструмент.** Она рендерится из
`registry-schema.ts`, поэтому добавление вида поля не трогает страницы.
- **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая
функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод.
- **Цепочка работает над набором файлов (fan-out).** `run-chain.ts`: «ток» между
шагами — массив именованных изображений (одиночная загрузка = набор из
одного); image-шаг применяется к каждому файлу, files-шаг (split) склеивает
имена частей (`<вход>-<часть>`), analyze-шаг (text/verdict) при batch-входе
видит первый файл. Шаг роняет цепочку, только если упали все файлы
(`ChainStepError`), частичные сбои — warnings. Мультивыбор файлов в drop-зоне;
набор на выходе скачивается zip через `zip.ts`.
## 3. Инструмент и страница
Реестр двухслойный: реализация и то, что видит пользователь, лежат в разных
файлах. `Tool` (`web/src/lib/registry/types.ts`, записи в
`registry/tools/<group>.ts`):
| Поле | Смысл |
| ---------- | ------------------------------------------------------------------------------ |
| `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker |
| `schema` | `toolSchema({ fields, layout?, meta? })` |
| `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе |
| `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
| `output`? | mime/ext/quality для скачивания (не у всех) |
| `runMask`? | ч/б превью «что изменится»; тогл на тайле результата, в цепочку не идёт |
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
`Page` (записи в `registry/pages/<group>.ts`):
| Поле | Смысл |
| ------------- | ---------------------------------------------------------------------- |
| `slug` | уникальный адрес `/tools/<slug>`, имя скачиваемого файла и ключ иконки |
| `title` | EN-строка страницы, переводится через `pages.<slug>.title` |
| `description` | EN-строка страницы, переводится через `pages.<slug>.description` |
| `category` | id из `categories.ts`; по нему каталог группирует страницы |
| `steps` | упорядоченный список шагов `{ id, params? }` — ссылки на инструменты |
Кто по чему адресуется: `slug` — маршрут, имя файла, `TOOL_ICONS`, поиск по
каталогу; `id` — ключ `tools.<id>.*` (подписи полей, `options`, `results`),
`toolId` worker-протокола и запись пайплайна. Модель допускает, что один
инструмент обслуживает несколько страниц, а страница состоит из нескольких
инструментов; на практике пока каждая страница ссылается на свой инструмент, а
исполняется первый (и единственный) шаг.
Инструмент работает с любым поддержанным форматом, поэтому png-интент живёт
только в `slug`: `id: "crop"` → страница `slug: "crop-png"` → `crop-png.png`.
Правило имени `id`: снять суффикс `-png` и в середине имени (`png-to-base64` →
`to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`). Правило
относится только к `id`: у `slug` своя история и SEO-значение, поэтому
переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя
файла, ключ `pages.*`, иконку и запись в `docs/tools-map.md`. Слаги, сложившиеся
до разделения реестра, правилу не соответствуют (`convert-png-to-jpg`,
`change-png-opacity`, `png-is-grayscale`, `verify-is-png` и другие) — это не
ошибка, менять их без SEO-решения нельзя. Форму slug проверяет
`registry.test.ts` (только форма, не смысл).
Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых
случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS`
и `PAGES` собираются в `registry/index.ts` из восьми групп; `getTool(id)` и
`getPageBySlug(slug)` — способы достать запись, `registry.test.ts` проверяет
уникальность обоих имён, существование инструмента под каждым шагом и то, что
шаг ровно один.
## 4. Схема параметров
`registry-schema.ts` — единственное место, где описаны виды полей.
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
`font-style`, `plate`, `gradient`.
- **Фабрика `field.*`** создаёт `Field<T>`; UI-контрол подбирается в
`SchemaFields.svelte` по `spec.kind` через карту `FIELDS`.
- **`schema.layout`** группирует поля (`resolveLayoutGroups`); раскладка
разделов — единственное место, где живёт порядок и группировка.
- **Значения по умолчанию** даёт `defaultSchemaParams`; `applySourceDefaults`
подставляет размеры текущего source; `sanitizeSchemaParams` приводит мусор к
допустимому виду. Правка поведения полей идёт здесь, а не в компоненте.
Новый вид поля требует: спека + фабрика `field.*` → запись в `fieldSpecs` →
контрол (`components/fields/schema/`) → ключ в `FIELDS` → дефолт и санитизация →
i18n-подписи → тест в `registry-schema.test.ts`.
## 5. Исполнение
`executor/executor.ts` — фабрика `createExecutor({ workerFactory? })` плюс
готовый экспорт `execute`. Контракт асинхронный (`Promise`), параметры
сериализуются по `executor/protocol.ts`, исполнение уходит в
`executor.worker.ts`; `worker-handler.ts` разбирает сообщение и зовёт `run`.
Если worker недоступен — прямой вызов. UI импортирует `execute` из
`executor/index.ts`.
Отсюда следует: подключение wasm позже означает замену реализации **внутри**
исполнителя, а не правку компонентов.
## 6. i18n
Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с
`LOCALES`/`BASE_LOCALE`, `t.ts` с `t()`, `locale.svelte.ts` с состоянием
локали). Пользовательский текст живёт только в словарях и читается через `t()`.
Списки ключей и допустимых атрибутов для i18n-правил линтера лежат не здесь, а в
`web/eslint-plugins/i18n/lists.js`. Секции разделены по слоям:
`pages.<slug>.title` и `pages.<slug>.description` — тексты страницы (в базовой
локали секция пустая, EN берётся из реестра),
`tools.<id>.options/params/results` — подписи полей и тексты вердиктов
инструмента, `fields.*`/`groups.*` — общие подписи схемы. Инварианты паритета
ключей, плейсхолдеров и запрет хардкода проверяются линтером, детали —
`web/eslint-plugins/README.md`.
## 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` |
## 8. Рецепт: добавить инструмент
1. **Найти соседей.** Поиск по ключу в `registry/tools/<group>.ts` и по всему
`web/src/` — не заводить дублирующую константу, тип или утилиту.
2. **Выбрать группу** (`geometry`/`alpha`/`color`/`convert`/`filters`/
`generate`/`text`/`analyze`) и положить инструмент в её экспорт; при новой
категории — сначала `categories.ts`.
3. **Задать `id`** по правилу раздела 3: без png-интента (`crop`, `to-jpg`,
`from-base64`, `is-grayscale`).
4. **Описать схему** через `field.*`, задать `layout`, если поля нужно
сгруппировать. Качество кодирования (`quality`) — не поле страницы, а
настройка скачивания (`docs/plan-seo.md` §4, S1f).
5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в
`core/`, без DOM.
6. **Добавить страницу** в `registry/pages/<group>.ts`: `slug` с адресным
png-интентом (`crop-png`), тексты EN, `category`, `steps: [{ id }]`.
7. **Проверить обязательные контракты**: инструмент попадает в `TOOLS`, страница
в `PAGES`, `/tools/<slug>` prerender-ится, `input`/`result` согласованы с
тем, что делает `run`.
8. **Добавить i18n-ключи во все локали** сразу: `pages.<slug>.title` и
`pages.<slug>.description`, при наличии опций и вердиктов — `tools.<id>`.
9. **Тесты:** unit — в `registry/registry.test.ts` или рядом с `core`-функцией;
e2e-smoke — существующий spec по типу `input`/`result` (правила —
`docs/testing-strategy.md`).
10. **Обновить `docs/tools-map.md`:** новая страница обязательна в разделе
«Реализовано», формат строки — в шапке раздела. Карта перечисляет **`slug`
страниц** из `registry/pages/`, то есть адреса. Проверяет
`web/src/lib/registry/tools-map-doc.test.ts`: slug без страницы и страница
без записи в карте роняют тест; разделы «Можно добавить» и «Идеи»
проверяются в обратную сторону — реализованный slug в плане тоже роняет
тест, поэтому при переезде инструмента из плана в реестр его вынимают
оттуда. Обоснование и границы автоматизации — `docs/decisions.md`, раздел 4.
Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для
инструмента, меняющего пользовательский поток, — e2e (см. раздел 3
`docs/quality-gates.md`).
## 9. Идиома: вынос логики компонента в `*.svelte.ts`
Крупный компонент-оркестратор (образец — `SchemaToolView.svelte`) держит в себе
только разметку, эффекты и пропсы; исполняемая логика живёт в модуле рядом
(`schema-tool-runner.svelte.ts`). Форма модуля продиктована лимитом
`max-lines-per-function` (100 строк, `web/eslint.config.js`): фабрика
`create*(ctx)` не вмещает все действия замыканиями, поэтому:
- состояние — один `$state`-объект (`RunnerState`), созданный внутри фабрики (не
модульный уровень — состояние на экземпляр компонента);
- действия — модульные функции `action(state, ctx)`; фабрика только создаёт
состояние и возвращает геттеры + привязанные вызовы;
- зависимости от компонента — `ctx` с ленивыми геттерами (`steps: () => steps`),
чтобы не копировать реактивные значения;
- компонент читает состояние через `$derived`-алиасы, пишет — только методами
фабрики.
Побочный плюс: действия тестируются как чистые функции над `(state, ctx)` без
монтирования компонента.