docs: update to slug - id changes

This commit is contained in:
2026-09-27 17:04:41 +05:00
parent 7b7e12fafc
commit 8cb3f1a8f0
9 changed files with 204 additions and 164 deletions
+6 -5
View File
@@ -70,11 +70,12 @@ pnpm build # static production export → web/build/
## Architecture ## Architecture
Everything is built around the **tool registry** (`web/src/lib/registry/`): each Everything is built around the **two-layer registry** (`web/src/lib/registry/`):
tool is a self-contained entry a tool is a self-contained implementation `{id, schema, input, result, run}`
`{id, title, description, category, schema, run}`. Pages, forms, and pipelines (`registry/tools/`), and a page is `{slug, title, description, category, steps}`
are generated from it, and all execution goes through a single (`registry/pages/`) that names the address and the tools to run — one tool can
`executor.execute` entry point. back several pages. Pages, forms, and pipelines are generated from it, and all
execution goes through a single `executor.execute` entry point.
Core image processing lives in `web/src/lib/core/` and operates directly on Core image processing lives in `web/src/lib/core/` and operates directly on
`ImageData`, with no browser API dependencies. `ImageData`, with no browser API dependencies.
+4 -4
View File
@@ -56,10 +56,10 @@
Единственный документ в архиве со статусом `active` — Единственный документ в архиве со статусом `active` —
`docs/archive/README.md`: он описывает процедуру архивирования, а не архивный `docs/archive/README.md`: он описывает процедуру архивирования, а не архивный
материал, поэтому не подлежит 30-дневному удалению. материал, поэтому не подлежит 30-дневному удалению.
- Ссылаясь на инструмент по id, сверять id с реестром (`web/src/lib/registry/`): - Ссылаясь на инструмент, сверяться с реестром: внутреннее имя (`id`) — в
исторические названия в старых планах не действуют. Сейчас `id` записи — это `web/src/lib/registry/tools/`, адрес и имя страницы (`slug`) — в
одновременно адрес страницы `/tools/<id>`, отдельного поля `slug` нет `web/src/lib/registry/pages/`; исторические названия в старых планах не
(`docs/architecture.md`, раздел 3). действуют (`docs/architecture.md`, раздел 3).
- Поиск по `docs/` задевает `docs/archive/` (объём больше, чем у активных - Поиск по `docs/` задевает `docs/archive/` (объём больше, чем у активных
документов вместе). Для правок production-кода архив не нужен: grep по документов вместе). Для правок production-кода архив не нужен: grep по
`docs/*.md` без `archive/`. `docs/*.md` без `archive/`.
+84 -61
View File
@@ -9,11 +9,13 @@
```txt ```txt
web/src/lib/ web/src/lib/
registry/ # источник истины: записи ToolEntry registry/ # источник истины: инструменты и страницы
registry/tools/ # Tool: реализации (id), без адресов и текстов
registry/pages/ # Page: адрес, тексты, категория, список шагов
registry-schema.ts # декларативная схема параметров + санитизация дефолтов registry-schema.ts # декларативная схема параметров + санитизация дефолтов
catalog.ts # группировка и счётчики для /list-tools catalog.ts # группировка страниц и счётчики для /list-tools
categories.ts # id/названия категорий categories.ts # id/названия категорий
tool-icons.ts # id → иконка tool-icons.ts # slug страницы → иконка
core/ # чистый TS: операции над ImageData, без DOM core/ # чистый TS: операции над ImageData, без DOM
executor/ # единственная точка исполнения инструментов executor/ # единственная точка исполнения инструментов
i18n/ # словари ru/en, t(), списки для i18n-правил линтера i18n/ # словари ru/en, t(), списки для i18n-правил линтера
@@ -23,7 +25,7 @@ web/src/lib/
web/src/routes/ web/src/routes/
+page.svelte # главная +page.svelte # главная
list-tools/ # каталог list-tools/ # каталог
tools/[id]/ # страница инструмента (адрес = id записи) tools/[slug]/ # страница инструмента (адрес = slug страницы)
kit/ # витрина компонентов (production, но исключена из i18n-гейта) kit/ # витрина компонентов (production, но исключена из i18n-гейта)
``` ```
@@ -34,15 +36,18 @@ web/src/routes/
### Чего в коде нет ### Чего в коде нет
Страница инструмента работает ровно с одним инструментом: загрузил файл → Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
применил одну запись реестра → скачал результат. Отсутствуют: скачала результат. Отсутствуют:
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для - **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для
него нет. `docs/roadmap.md` описывает его как отдельную фазу, а не как него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
поставленное. Под будущую фазу зарезервированы `components/StepCard.svelte` а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
полей двух инструментов — отдельная работа, описанная как фаза 11
`docs/roadmap.md`. Под неё зарезервированы `components/StepCard.svelte`
(сейчас импортируется только витриной `/kit`) и ключи `chain.*`, (сейчас импортируется только витриной `/kit`) и ключи `chain.*`,
`errors.badPipelineShape`, `errors.noSteps`. `errors.badPipelineShape`, `errors.noSteps`.
- **Разделения `id` и `slug`** — раздел 3. - **Разделения страниц, которые делят один инструмент** — сводит 14
convert-страниц на один инструмент; задача в `docs/backlog.md`.
Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по
пайплайну — `docs/roadmap.md`. пайплайну — `docs/roadmap.md`.
@@ -50,15 +55,15 @@ web/src/routes/
## 2. Поток данных ## 2. Поток данных
```txt ```txt
registry/*.ts ──(TOOLS: ToolEntry[]) registry/{tools,pages}/*.ts
│ │
├─► catalog.ts ──► routes/list-tools (каталог, поиск, фильтры) ├─► catalog.ts ──► routes/list-tools (каталог страниц, поиск, фильтры)
│ │
└─► routes/tools/[id]/+page.svelte └─► routes/tools/[slug]/+page.svelte
│ getTool(id) → запись реестра │ getPageBySlug(slug) → Page, getTool(steps[0].id) → Tool
▼ ▼
SchemaToolView.svelte оркестратор страницы SchemaToolView.svelte оркестратор страницы
│ schema = tool.schema │ schema = tool.schema, тексты и имя файла — от page
├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол) ├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол)
├─► SchemaSourceTile/SchemaTextSource.svelte вход ├─► SchemaSourceTile/SchemaTextSource.svelte вход
└─► SchemaPreview + schema-preview-model.ts вид превью └─► SchemaPreview + schema-preview-model.ts вид превью
@@ -71,14 +76,15 @@ registry/*.ts ──(TOOLS: ToolEntry[])
│ │
▼ ▼
SchemaResultTile / SchemaTextResult + SchemaActions (download/copy) SchemaResultTile / SchemaTextResult + SchemaActions (download/copy)
│ кодирование по ToolEntry.output (mime/ext/quality) │ кодирование по Tool.output (mime/ext/quality)
└─► zip.ts, если result === "files" └─► zip.ts, если result === "files"
``` ```
Ключевые решения: Ключевые решения:
- **Реестр — единственный источник истины.** Страница и форма строятся из - **Реестр — единственный источник истины.** Страница и форма строятся из
записи; новый инструмент = новая запись + функция `run`. записей; новый инструмент = новая запись `Tool` + функция `run` и новая
страница `Page` на него.
- **Исполнение идёт только через `executor.execute`.** Компоненты не зовут - **Исполнение идёт только через `executor.execute`.** Компоненты не зовут
`tool.run` напрямую — иначе нельзя перенести тяжёлое в worker/wasm без правки `tool.run` напрямую — иначе нельзя перенести тяжёлое в worker/wasm без правки
вызывающего кода. вызывающего кода.
@@ -87,39 +93,50 @@ registry/*.ts ──(TOOLS: ToolEntry[])
- **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая - **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая
функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод. функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод.
## 3. Запись реестра ## 3. Инструмент и страница
`ToolEntry` (`web/src/lib/registry/types.ts`): Реестр двухслойный: реализация и то, что видит пользователь, лежат в разных
файлах. `Tool` (`web/src/lib/registry/types.ts`, записи в
`registry/tools/<group>.ts`):
| Поле | Смысл | | Поле | Смысл |
| ------------- | ------------------------------------------------------------------------------------------------ | | ---------- | ------------------------------------------------------------------------------ |
| `id` | идентичность инструмента **и** публичный адрес `/tools/<id>` **и** база имени скачиваемого файла | | `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker |
| `title` | EN-строка реестра, переводится через `tools.*.title` |
| `description` | EN-строка реестра, переводится через `tools.*.description` |
| `category` | id из `categories.ts` |
| `schema` | `toolSchema({ fields, layout?, meta? })` | | `schema` | `toolSchema({ fields, layout?, meta? })` |
| `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе | | `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе |
| `result` | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт | | `result` | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
| `domOnly` | инструмент требует DOM (canvas); превью-executor запускает его напрямую, минуя worker | | `domOnly` | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
| `output` | mime/ext/quality для download (не у всех инструментов) | | `output` | mime/ext/quality для скачивания (не у всех) |
| `icon` | id иконки из `tool-icons.ts` |
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` | | `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
Отдельного поля `slug` **нет**: адрес страницы, ключ i18n (`tools.<id>.*`) и имя `Page` (записи в `registry/pages/<group>.ts`):
файла скачивания — это один и тот же `id`. Инструмент работает с любым
поддержанным форматом, но страница держит png-интент, поэтому `-png` живёт | Поле | Смысл |
внутри `id` (`id: "crop-png"` → `/tools/crop-png` → `crop-png.png`). Следствие: | ------------- | ---------------------------------------------------------------------- |
переименование `id` меняет адрес страницы, ключи `tools.*` и имя файла. | `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`.
Правило имени: снять суффикс `-png` и в середине имени (`png-to-base64` →
`to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`); следствие —
переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя
файла, ключ `pages.*`, иконку и запись в `docs/tools-map.md`.
Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых
случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS` случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS`
собирается в `registry/index.ts` из восьми групп; `getTool(id)` — единственный и `PAGES` собираются в `registry/index.ts` из восьми групп; `getTool(id)` и
способ достать запись, `registry.test.ts` проверяет уникальность `id`. `getPageBySlug(slug)` — способы достать запись, `registry.test.ts` проверяет
уникальность обоих имён, существование инструмента под каждым шагом и то, что
Разделение `id` (внутреннее имя) и `slug` (адрес) — решённое, но **не шаг ровно один.
реализованное** решение: задача и порядок волн в `docs/backlog.md`, раздел
«Мелочи всякие». До её выполнения этот документ описывает текущее состояние, а
id и slug совпадают.
## 4. Схема параметров ## 4. Схема параметров
@@ -154,17 +171,21 @@ i18n-подписи → тест в `registry-schema.test.ts`.
Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с
`LOCALES`/`BASE_LOCALE`). Пользовательский текст живёт только в словарях и `LOCALES`/`BASE_LOCALE`). Пользовательский текст живёт только в словарях и
читается через `t()`; строки реестра (`title`/`description`) — источник для читается через `t()`. Секции разделены по слоям: `pages.<slug>.title` и
EN-ключей `tools.*` и словарь-переводчик. Инварианты паритета ключей, `pages.<slug>.description` — тексты страницы (в базовой локали секция пустая, EN
плейсхолдеров и запрет хардкода проверяются линтером, детали — берётся из реестра), `tools.<id>.options/params/results` — подписи полей и
`web/eslint-plugins/README.md`. тексты вердиктов инструмента, `fields.*`/`groups.*` — общие подписи схемы.
Инварианты паритета ключей, плейсхолдеров и запрет хардкода проверяются
линтером, детали — `web/eslint-plugins/README.md`.
## 7. Точки расширения ## 7. Точки расширения
| Задача | Где править | | Задача | Где править |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Новый инструмент | запись в `registry/<group>.ts` + i18n-ключи + тесты (рецепт ниже) | | Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) |
| Смена `id` | запись реестра + i18n-ключи `tools.*` + `tool-icons.ts` + хардкод id в тестах, `/kit` и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи | | Новая страница на готовом инструменте | запись `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) | | Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге | | Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` | | Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
@@ -174,30 +195,32 @@ EN-ключей `tools.*` и словарь-переводчик. Инвариа
## 8. Рецепт: добавить инструмент ## 8. Рецепт: добавить инструмент
1. **Найти соседей.** Поиск по ключу в `registry/<group>.ts` и по всему 1. **Найти соседей.** Поиск по ключу в `registry/tools/<group>.ts` и по всему
`web/src/` — не заводить дублирующую константу, тип или утилиту. `web/src/` — не заводить дублирующую константу, тип или утилиту.
2. **Выбрать группу** (`geometry`/`alpha`/`color`/`convert`/`filters`/ 2. **Выбрать группу** (`geometry`/`alpha`/`color`/`convert`/`filters`/
`generate`/`text`/`analyze`) и положить запись в её экспорт; при новой `generate`/`text`/`analyze`) и положить инструмент в её экспорт; при новой
категории — сначала `categories.ts`. категории — сначала `categories.ts`.
3. **Задать `id`.** Он же адрес страницы, он же ключ i18n, он же база имени 3. **Задать `id`** по правилу раздела 3: без png-интента (`crop`, `to-jpg`,
скачиваемого файла. Страница держит png-интент, поэтому суффикс `-png` входит `from-base64`, `is-grayscale`).
в `id` (`crop-png`, `resize-png`) — это осознанно, отдельного поля `slug` в
реестре нет (раздел 3).
4. **Описать схему** через `field.*`, задать `layout`, если поля нужно 4. **Описать схему** через `field.*`, задать `layout`, если поля нужно
сгруппировать. сгруппировать. Качество кодирования (`quality`) — не поле страницы, а
настройка скачивания (`docs/plan-seo.md` §1, S1f).
5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в 5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в
`core/`, без DOM. `core/`, без DOM.
6. **Проверить обязательные контракты** для новой записи: она попадает в 6. **Добавить страницу** в `registry/pages/<group>.ts`: `slug` с адресным
`TOOLS`, страница `/tools/<id>` prerender-ится, `input`/`result` согласованы png-интентом (`crop-png`), тексты EN, `category`, `steps: [{ id }]`.
с тем, что делает `run`. 7. **Проверить обязательные контракты**: инструмент попадает в `TOOLS`, страница
7. **Добавить i18n-ключи во все локали** сразу (ключ — по `id`). в `PAGES`, `/tools/<slug>` prerender-ится, `input`/`result` согласованы с
8. **Тесты:** unit — в `registry/*.test.ts` или рядом с `core`-функцией; тем, что делает `run`.
8. **Добавить i18n-ключи во все локали** сразу: `pages.<slug>.title` и
`pages.<slug>.description`, при наличии опций и вердиктов — `tools.<id>`.
9. **Тесты:** unit — в `registry/registry.test.ts` или рядом с `core`-функцией;
e2e-smoke — существующий spec по типу `input`/`result` (правила — e2e-smoke — существующий spec по типу `input`/`result` (правила —
`docs/testing-strategy.md`). `docs/testing-strategy.md`).
9. **Обновить `docs/tools-map.md`:** новая страница обязательна в разделе 10. **Обновить `docs/tools-map.md`:** новая страница обязательна в разделе
«Реализовано», формат строки — в шапке раздела. Карта перечисляет **`id`** «Реализовано», формат строки — в шапке раздела. Карта перечисляет **`slug`
реестра, которые одновременно являются адресами страниц. Проверяет страниц** из `registry/pages/`, то есть адреса. Проверяет
`web/src/lib/registry/tools-map-doc.test.ts`: id без инструмента и инструмент `web/src/lib/registry/tools-map-doc.test.ts`: slug без страницы и страница
без записи в карте роняют тест. Обоснование и границы автоматизации — без записи в карте роняют тест. Обоснование и границы автоматизации —
`docs/decisions.md`, раздел 4. `docs/decisions.md`, раздел 4.
+18 -29
View File
@@ -72,28 +72,17 @@
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было - [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
изменено изменено
- [ ] обновить страницу kit или сделать storybook - [ ] обновить страницу kit или сделать storybook
- [ ] Слаги инструментов (`*-png`) — **развести `id` и адрес страницы.** - [ ] Свести convert-страницы на один инструмент `convert` — реализация уже
Инструменты работают с любым форматом, но называются `crop-png`, умеет «скопировать пиксели, декодировать вход, отдать в заданном формате»,
`resize-png`; имя в коде, пайплайнах и i18n должно быть `crop`, `resize`, а страницы различаются только форматом вывода. **Зависит от S1f**
а адрес и имя скачиваемого файла — прежними (`/tools/crop-png` → (`docs/plan-seo.md` §1): пока `quality` живёт в схеме страницы, у
`crop-png.png`). Решение и его SEO-последствия: `docs/plan-seo.md` §0; `convert-png-to-bmp` и `convert-png-to-jpg` разные наборы полей, и один
текущее состояние (поля `slug` нет, роут — `tools/[id]`) — инструмент на них не сойдётся. Качество — настройка кнопки Download
`docs/architecture.md`, раздел 3. Порядок работ: 1. добавить `slug` в («Download as JPEG»), а не поле страницы; после его переезда пресеты схемы
`ToolEntry` + `toolSlug()` / `getToolBySlug()` в `registry/index.ts`, не нужны, страницы различаются только `output` и `params` шага. Модель
проверку уникальности `slug` в `registry.test.ts`; 2. перевести страницы к этому готова: `Page.steps` — список инструментов, один
`routes/tools/[id]` → `[slug]` (`entries()`, `load()`, `+page.svelte`), инструмент может обслуживать много страниц (`docs/architecture.md`, раздел
`ToolCard`, крошку в `+layout.svelte` и имя файла download в 3).
`SchemaToolView` на `toolSlug(tool)`; старые адреса продолжают работать,
редиректы не нужны; 3. переименовать `id` волнами по группам реестра
(`convert`, `alpha`, `color`, `geometry`, `filters`, `analyze`,
`generate`, `text`); в каждой волне — `registry/<group>.ts`, ключи
`tools.*` в `en.ts`/`ru.ts`, `tool-icons.ts`, захардкоженные id в тестах и
e2e (`smoke-i18n.test.ts`, `kit/+page.svelte`); 4. `docs/tools-map.md` при
этом **не меняется**: карта перечисляет id, а они остаются прежними как
адреса. Обратной совместимости не делаем: формат пайплайна ещё не
устоялся, старые экспорты и записи в localStorage после переименования
просто перестают открываться — это осознанно, миграция усложнила бы код
ради пары пользователей.
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop, - [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
удаление, добавление инструментов. переход один инструмент - pipeline без удаление, добавление инструментов. переход один инструмент - pipeline без
перезагрузки перезагрузки
@@ -193,21 +182,21 @@ UI. Оценки (S/M/L) — оценка труда, не приоритет.
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на 5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
карточках каталога, результатах поиска и в шапке страницы инструмента; список карточках каталога, результатах поиска и в шапке страницы инструмента; список
id в localStorage. Избранное показывается отдельной секцией сверху каталога и slug в localStorage. Избранное показывается отдельной секцией сверху каталога
поднимается в выдаче поиска (бонус к popularity при скоринге). и поднимается в выдаче поиска (бонус к popularity при скоринге).
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download** 6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
— оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и — оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и
задача **S1f**; почему селектор не заменяет существующие convert-страницы — задача **S1f**; почему селектор не заменяет существующие convert-страницы —
`docs/plan-seo.md` §5. Механическая часть уже есть: `ToolEntry.output` `docs/plan-seo.md` §5. Механическая часть уже есть: `Tool.output`
(`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в (`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в
`SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось `SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось
сверх S1f: сверх S1f:
- экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png` - экспорт с лимитом размера → закрывает отложенную страницу
(целевой KB, бинарный поиск по k из квантования). Проработать вместе с `reduce-to-size-png` (целевой KB, бинарный поиск по k из квантования).
достройкой download-флоу. Проработать вместе с достройкой download-флоу.
7. **`watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна 7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна
overlay-механика: второй источник изображения (getOverlay/store), параметры overlay-механика: второй источник изображения (getOverlay/store), параметры
scale/opacity/position/margin/aspect. Решается вместе с вопросом, как второй scale/opacity/position/margin/aspect. Решается вместе с вопросом, как второй
источник картинки вписывается в schema-driven preview (`SchemaToolView`). источник картинки вписывается в schema-driven preview (`SchemaToolView`).
+22 -5
View File
@@ -64,16 +64,18 @@
Правило про числа не закрывает более опасный класс расхождений: документ не Правило про числа не закрывает более опасный класс расхождений: документ не
устарел числом, а **перечисляет то, чего нет в коде**. В разделе «Реализовано» устарел числом, а **перечисляет то, чего нет в коде**. В разделе «Реализовано»
карты лежали несуществующие id (`png-info`, `color-wheel-generator`, карты лежали несуществующие страницы (`png-info`, `color-wheel-generator`,
`jpg-to-png`, `watermark-image-png`), которые агент принял бы за готовые `jpg-to-png`, `watermark-image-png`), которые агент принял бы за готовые
инструменты. Счёт при этом был верный — число такой класс не ловит. инструменты. Счёт при этом был верный — число такой класс не ловит.
Сверка множеств отвечает на вопрос однозначно и без allowlist, поэтому Сверка множеств отвечает на вопрос однозначно и без allowlist, поэтому
автоматизирована: `web/src/lib/registry/tools-map-doc.test.ts` парсит раздел автоматизирована: `web/src/lib/registry/tools-map-doc.test.ts` парсит раздел
«Реализовано» и сравнивает id с `TOOLS` в обе стороны (id без инструмента и «Реализовано» и сравнивает slug с `PAGES` в обе стороны (slug без страницы и
инструмент без записи), плюс ловит повтор id между буллетами. Формат строки страница без записи), плюс ловит повтор slug между буллетами. Формат строки
карты — всё до первого тире, id через `/` или `,` — закреплён в самом документе карты — всё до первого тире, slug через `/` или `,` — закреплён в самом
и в тесте. документе и в тесте. Сверяются именно страницы, а не инструменты: карта
описывает то, что видит пользователь, а один инструмент может обслуживать
несколько страниц (см. `docs/architecture.md`, раздел 3).
Чтобы сломанный парсер не выглядел как успех, у теста есть отдельная проверка Чтобы сломанный парсер не выглядел как успех, у теста есть отдельная проверка
«разбор не пустой»: если формат карты снова поменяется, тест упадёт с сообщением «разбор не пустой»: если формат карты снова поменяется, тест упадёт с сообщением
@@ -153,3 +155,18 @@ PR. Актуальные длительности — в логе прогона
5. красный `e2e` только из-за ресурсов раннера фиксируется как ограничение 5. красный `e2e` только из-за ресурсов раннера фиксируется как ограничение
окружения: сначала проверяются один проект и один spec окружения: сначала проверяются один проект и один spec
(`docs/agent-workflow.md`, раздел 3), а не увеличиваются таймауты. (`docs/agent-workflow.md`, раздел 3), а не увеличиваются таймауты.
## 11. Тексты страниц: в базовой локали секция `pages` пустая
Заголовки и описания страниц переводятся (`pages.<slug>.*`), а EN-строка лежит в
реестре страниц — дублировать её в `en.ts` нельзя, иначе при правке реестра
английский текст разъедется со страницей, а `dict-consistency` этого не увидит.
Поэтому `en.ts` объявляет `pages: {}` — пустую секцию, а линтер правила
`i18n/dict-consistency` не считает это нарушением: в базовой локали отсутствие
`pages.*.title/description` разрешено опцией `baseLocaleFallback`, а полнота
перевода для остальных локалей проверяется строго и юнит-тестом
(`tools-i18n.test.ts`).
Альтернатива — `allowPaths: ["pages"]` — была бы слабее: она разрешила бы пустую
секцию `pages` в любой локали, то есть пропущенный перевод страницы прошёл бы
линтер молча. Пустая секция в EN и есть то, что делает правило точным.
+7 -4
View File
@@ -16,10 +16,13 @@
> которые в бэклоге, здесь не дублируются: этот документ задаёт **порядок**, > которые в бэклоге, здесь не дублируются: этот документ задаёт **порядок**,
> backlog — **учёт** (`docs/backlog.md`). > backlog — **учёт** (`docs/backlog.md`).
> >
> **Ids инструментов сверены с реестром** (`web/src/lib/registry/`) для шагов > **Инструменты в этом документе названы по адресам страниц (slug).** В коде у
> FA-B и волн C–E. Названия из `docs/tools-audit.md` вида `png-to-hsl`, > страницы `slug` (`registry/pages/`), у вызываемого инструмента — `id` без
> `color-wheel-generator`, `webp-to-png` в реестре отсутствуют (реальные — > png-интента (`crop-png` → `id: "crop"`, `png-to-hsl` → `to-hsl`), модель —
> `hsl`, `color-wheel-png`): при переносе пункта аудита в шаг id сверять. > `docs/architecture.md`, раздел 3. Названия из `docs/tools-audit.md` вида
> `color-wheel-generator`, `webp-to-png` в реестре отсутствуют (реальная
> страница — `color-wheel-png`): при переносе пункта аудита в шаг сверять с
> `PAGES`.
## 0. Ключевое решение: select → buttons ## 0. Ключевое решение: select → buttons
+35 -29
View File
@@ -1,11 +1,11 @@
# План: SEO + GEO — индексация и конкуренция в image tools # План: SEO + GEO — индексация и конкуренция в image tools
> **Статус: active.** Адреса страниц инструментов — `/tools/<id>` (`crop-png` и > **Статус: active.** Адреса страниц инструментов — `/tools/<slug>` (`crop-png`
> подобные), каталог — `/list-tools`; отдельного поля `slug` в реестре пока нет, > и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug`
> `id` и есть адрес. > ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3).
> >
> **`noindex, nofollow` выставлен на каждой странице инструмента** > **`noindex, nofollow` выставлен на каждой странице инструмента**
> (`web/src/routes/tools/[id]/+page.svelte`) — осознанно, до появления > (`web/src/routes/tools/[slug]/+page.svelte`) — осознанно, до появления
> уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и > уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и
> **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают > **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают
> эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо > эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо
@@ -40,22 +40,24 @@
- **Форматы: формат = выбор в UI, не отдельные страницы.** Download-кнопка - **Форматы: формат = выбор в UI, не отдельные страницы.** Download-кнопка
каждого инструмента — с селектором PNG/JPG/WebP/BMP (+quality для lossy). Райз каждого инструмента — с селектором PNG/JPG/WebP/BMP (+quality для lossy). Райз
«выбор формата/качества в Download» вытащен из backlog №7 в S1f: механика уже «выбор формата/качества в Download» вытащен из backlog №7 в S1f: механика уже
есть (`ToolEntry.output`), нужна как продуктовая основа «image есть (`Tool.output`), нужна как продуктовая основа «image
tools»-позиционирования. В направлении «из формата в PNG» отдельных tools»-позиционирования. В направлении «из формата в PNG» отдельных
инструментов нет (загрузка принимает любой формат) — см. §5. инструментов нет (загрузка принимает любой формат) — см. §5.
- **Адреса страниц `-png` сохраняются, внутренние имена — без.** Инструмент - **Адреса страниц `-png` сохраняются, внутренние имена — без.** Инструмент
работает с любым форматом, поэтому его `id` не должен врать (`crop`, а не работает с любым форматом, поэтому его `id` не должен врать (`crop`, а не
`crop-png`); png-интент держит адрес страницы и имя скачиваемого файла. `crop-png`); png-интент держат адрес страницы и имя скачиваемого файла.
Требуется поле `slug` в `ToolEntry` + переход `routes/tools/[id]` → `[slug]`. **Реализовано:** реестр разделён на инструменты (`registry/tools/`, уникальный
**Сейчас не реализовано:** в реестре `id: "crop-png"`, поля `slug` нет, роут — `id`) и страницы (`registry/pages/`, уникальный `slug`), роут —
`tools/[id]/`; текущее состояние описано в `docs/architecture.md`, раздел 3. `tools/[slug]/`; модель — `docs/architecture.md`, раздел 3. **Следствие для
**Следствие для SEO: редиректы не нужны, canonical и sitemap не меняются, SEO: редиректы не понадобились, canonical и sitemap не меняются, адреса те
адреса остаются теми же** — поэтому это безопасная правка, которую можно же** — поэтому это была безопасная правка до индексации, а не после. Побочная
делать до индексации, а не после. Побочная выгода: широкие интенты («crop выгода: широкие интенты («crop image online») переносятся теми же страницами
image online») переносятся теми же страницами контентом — H1 «Crop image», контентом — H1 «Crop image», текст «works with PNG, JPG, WebP»; URL-алиасы без
текст «works with PNG, JPG, WebP»; URL-алиасы без `-png` по-прежнему не `-png` по-прежнему не создаются (канонический беспорядок). Побочный эффект
создаются (канонический беспорядок). Порядок работ — `docs/backlog.md`, задача модели: страница может ссылаться на один инструмент, а инструмент —
«Слаги инструментов (`*-png`) — развести `id` и адрес страницы». обслуживать много страниц, поэтому следующие шаги (свести convert-страницы на
один `convert`, селектор формата в Download — S1f) не требуют новой миграции
реестра. Задача на консолидацию — `docs/backlog.md`.
- **Индексируемая локаль — EN** (базовая; конкурент англоязычный). RU-версия — - **Индексируемая локаль — EN** (базовая; конкурент англоязычный). RU-версия —
отдельно и позже: префикс `/ru/`, prerender обеих локалей, hreflang отдельно и позже: префикс `/ru/`, prerender обеих локалей, hreflang
(сознательно отложено). Пока язык переключается на клиенте (localStorage) — (сознательно отложено). Пока язык переключается на клиенте (localStorage) —
@@ -65,11 +67,11 @@
canonical и «корневость» страниц. Свой домен + корень. Текущий деплой — GitHub canonical и «корневость» страниц. Свой домен + корень. Текущий деплой — GitHub
Pages (`.github/workflows/deploy.yml`); перенос на свой домен выполняется Pages (`.github/workflows/deploy.yml`); перенос на свой домен выполняется
сменой origin в CI и `PUBLIC_SITE_ORIGIN`. сменой origin в CI и `PUBLIC_SITE_ORIGIN`.
- **URL-схема инструментов:** короткие плоские адреса — `/tools/<id>` для - **URL-схема инструментов:** короткие плоские адреса — `/tools/<slug>` для
инструмента, `/list-tools` для каталога, где `<id>` берётся из записи реестра. страницы, `/list-tools` для каталога, где `<slug>` берётся из записи
Схему не менять без отдельного SEO-плана: после индексации переименование URL `registry/pages/`. Схему не менять без отдельного SEO-плана: после индексации
означает 301 и потерю позиций. Разделение `id`/`slug` (решение выше) адресов переименование URL означает 301 и потерю позиций. Разделение инструмента и
не меняет. страницы (решение выше) адресов не меняет.
- **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или - **Без GA и рекламных скриптов:** аналитика — self-hosted Plausible/Umami или
Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя Cloudflare Web Analytics. «No tracking» — часть позиционирования, её нельзя
подрывать собственными скриптами. подрывать собственными скриптами.
@@ -83,9 +85,10 @@
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт - SvelteKit + `adapter-static`, `prerender = true` на весь сайт
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен (`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
JS. Это главный технический козырь. JS. Это главный технический козырь.
- Инструменты в `web/src/lib/registry` с «SEO-шными» id (`crop-png`, - Страницы в `web/src/lib/registry/pages` со «SEO-шными» slug (`crop-png`,
`add-border-png`, …) — URL уже повторяют паттерн конкурента. Актуальный `add-border-png`, …) — URL уже повторяют паттерн конкурента, при этом
состав: `docs/tools-map.md`. внутренние имена инструментов png-интента не держат. Актуальный состав:
`docs/tools-map.md`.
- На корневых страницах инструментов есть базовые `<title>` и `robots`; полный - На корневых страницах инструментов есть базовые `<title>` и `robots`; полный
SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2. SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2.
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта - Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
@@ -160,7 +163,7 @@
**domain-required** (после покупки и DNS-настройки). **domain-required** (после покупки и DNS-настройки).
- [ ] **[S1h]** Снять `noindex, nofollow` со страниц инструментов - [ ] **[S1h]** Снять `noindex, nofollow` со страниц инструментов
(`web/src/routes/tools/[id]/+page.svelte`) — заменить на (`web/src/routes/tools/[slug]/+page.svelte`) — заменить на
`<meta name="robots" content="index, follow" />`. **Это первым `<meta name="robots" content="index, follow" />`. **Это первым
обязательным шагом S1:** пока тег стоит, остальные задачи S1 готовят обязательным шагом S1:** пока тег стоит, остальные задачи S1 готовят
инфраструктуру, но не приводят трафик. Порядок: S1h ставится после инфраструктуру, но не приводят трафик. Порядок: S1h ставится после
@@ -172,14 +175,16 @@
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на - [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд, существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
файл `static/og/[id].png`, ссылка из `Seo.svelte`. Относительные пути файл `static/og/[slug].png`, ссылка из `Seo.svelte`. Относительные пути
(`/og/crop-png.png`) работают без домена; при деплое на собственном домене (`/og/crop-png.png`) работают без домена; при деплое на собственном домене
OG-картинки будут абсолютными и корректными. OG-картинки будут абсолютными и корректными.
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality - [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть для lossy) для всех инструментов — механика `Tool.output` уже есть
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа (`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести «image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
параллельно с текущими quality-задачами. параллельно с текущими quality-задачами. Побочно снимает `quality` из схем
страниц — после этого 14 convert-страниц сводятся на один инструмент
(`docs/backlog.md`).
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов - [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в
png. Существующие страницы не трогать: `convert-png-to-jpg`, png. Существующие страницы не трогать: `convert-png-to-jpg`,
@@ -350,7 +355,8 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
**один будущий инструмент `/convert-image`**: сначала пустой **core-tool** в **один будущий инструмент `/convert-image`**: сначала пустой **core-tool** в
`core/` — функция ре-энкода («копия с перекодированием»: декодирует любой `core/` — функция ре-энкода («копия с перекодированием»: декодирует любой
поддержанный исходник в ImageData и кодирует в выбранный формат; поверх неё поддержанный исходник в ImageData и кодирует в выбранный формат; поверх неё
S1f строит селектор формата). `ToolEntry` и страницу добавлять **только вместе S1f строит селектор формата). Инструмент и страницу добавлять **только вместе
с собственным текстом** («Convert any image to PNG, JPG or WebP in your с собственным текстом** («Convert any image to PNG, JPG or WebP in your
browser») — core в коде не обязывает к странице. Этот же шаблон потом browser») — core в коде не обязывает к странице. Этот же шаблон потом
закрывает форматные волны (jpg→webp и т.п.) без новых страниц. закрывает форматные волны (jpg→webp и т.п.) без новых страниц.
+4 -2
View File
@@ -132,9 +132,11 @@ Manifest + service worker, оффлайн-режим («работает без
исполнителе и реестре, поэтому может идти параллельно с SEO. Номер 11 выбран исполнителе и реестре, поэтому может идти параллельно с SEO. Номер 11 выбран
чтобы не сдвигать уже используемые ссылки на фазы 2–10. чтобы не сдвигать уже используемые ссылки на фазы 2–10.
- **Формат шага** — `{tool, params}`, список шагов применяется последовательно, - **Формат шага** — `{id, params}`, список шагов применяется последовательно,
результат предыдущего идёт в следующий. Формат зафиксирован в §0 как общий для результат предыдущего идёт в следующий. Формат зафиксирован в §0 как общий для
браузера, wasm и CLI, поэтому версионируется с первого дня. браузера, wasm и CLI, поэтому версионируется с первого дня. Он уже совпадает с
полем `Page.steps` (`{ id, params? }`), так что страница может быть готовой
цепочкой без новой модели данных; исполняется пока первый шаг.
- **Первый шаг фазы:** модуль состояния цепочки + сохранение в localStorage + - **Первый шаг фазы:** модуль состояния цепочки + сохранение в localStorage +
экспорт/импорт JSON (ошибки разбора уже описаны ключами экспорт/импорт JSON (ошибки разбора уже описаны ключами
`errors.badPipelineShape` / `errors.pipelineVersion` / `errors.noSteps`). `errors.badPipelineShape` / `errors.pipelineVersion` / `errors.noSteps`).
+10 -11
View File
@@ -2,13 +2,12 @@
> Статус: **active**. Живой документ для сверки с каталогом. > Статус: **active**. Живой документ для сверки с каталогом.
> >
> **Карта перечисляет `id` записи реестра (`web/src/lib/registry/`), которые > **Карта перечисляет `slug` страниц (`web/src/lib/registry/pages/`) — то есть
> одновременно являются адресами страниц:** `/tools/crop-png` — это > адреса вида `/tools/crop-png`:** это `slug: "crop-png"` у страницы, которая
> `id: "crop-png"`. Отдельного поля `slug` в `ToolEntry` нет, поэтому id и адрес > вызывает инструмент `id: "crop"`. Внутренние имена инструментов png-интента не
> сейчас совпадают; их разделение — решённая, но не реализованная задача > держат, поэтому они от карты отделены; модель — `docs/architecture.md`,
> (`docs/backlog.md`, раздел «Мелочи всякие», и `docs/architecture.md`, раздел > раздел 3. Источник правды — реестр. Этот список навигационный: сверять перед
> 3). Источник правды — реестр. Этот список навигационный: сверять перед > использованием. Формат записи: `slug — параметры через запятую`. Сравнение с
> использованием. Формат записи: `id — параметры через запятую`. Сравнение с
> каталогом источника (объём, нишевые клоны серий Logo*/Icon*/Stamp*Signature*) > каталогом источника (объём, нишевые клоны серий Logo*/Icon*/Stamp*Signature*)
> — замер в `docs/plan-seo.md`, §2; здесь он не дублируется. > — замер в `docs/plan-seo.md`, §2; здесь он не дублируется.
> >
@@ -20,13 +19,13 @@
## 1. Реализовано ## 1. Реализовано
Ids приведены к фактическим в `web/src/lib/registry/`. Ids приведены к фактическим в `web/src/lib/registry/pages/`.
**Формат строки (проверяется тестом **Формат строки (проверяется тестом
`web/src/lib/registry/tools-map-doc.test.ts`):** в буллите всё **до первого `web/src/lib/registry/tools-map-doc.test.ts`):** в буллите всё **до первого
тире** `—` — это id инструментов через `/` или `,`; всё после тире — свободное тире** `—` — это slug страниц через `/` или `,`; всё после тире — свободное
описание параметров и не разбирается. Второй id нельзя оставлять после тире, описание параметров и не разбирается. Второй slug нельзя оставлять после тире,
иначе он не попадёт в разбор и инструмент будет считаться неописанным. иначе он не попадёт в разбор и страница будет считаться неописанной.
### Конвертация (`convert.ts`) ### Конвертация (`convert.ts`)