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

26 KiB
Raw Blame History

Архитектура

Статус: active. Описание текущего кода: границы модулей, поток данных от реестра до скачивания, точки расширения. Канонический источник истины — сам код; этот документ объясняет, куда смотреть и что с чем связано, и не дублирует сигнатуры.

1. Слои

web/src/lib/
  registry/            # источник истины: инструменты и страницы
  registry/tools/      # Tool: реализации (id), без адресов и текстов
  registry/pages/      # Page: адрес, тексты, категория, список шагов
  registry-schema/   # декларативная схема параметров: specs (типы полей),
                     # field (builder DSL), layout (группы), aspect (aspect-lock
                     # и кэпы от source), values (дефолты и санитизация);
                     # index.ts — баррель, импорты идут по `registry-schema`
  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. Поток данных

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/ — единственное место, где описаны виды полей. Модули: specs.ts (типы полей и ToolSchema), field.ts (фабрика field.* и toolSchema), layout.ts (resolveLayoutGroups), aspect.ts (effectiveMax, clampSourceAwareMaxes, withAspectLock), values.ts (defaultSchemaParams, applySourceDefaults, sanitizeSchemaParams).

  • Виды поля (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.* в словарях + 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. Рецепт: добавить инструмент

  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) без монтирования компонента.