diff --git a/AGENTS.md b/AGENTS.md
index 08dd471..724cc80 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,7 +55,9 @@
закрытия.
- **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n,
UI и рефакторинг.
-- **Не коммитить.** Коммит и пуш делает разработчик после ревью.
+- **Не коммитить.** Коммит и пуш делает разработчик после ревью. **И не
+ напоминать про них:** пользователь сам решает, когда коммитить и деплоить, — в
+ отчёте о шаге этого не предлагать.
- **Не запускать тяжёлое без спроса.** Playwright — только по явной команде
пользователя, один прогон, без циклов повторов и фонового запуска.
- **Не писать изменяемые числа** (количество инструментов, тестов, локалей,
@@ -102,6 +104,10 @@
`completed`, `archived`).
- Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и
ссылка на канонический документ.
+- **Отмеченная `[x]` задача — короткая запись:** что решили и почему, без дат,
+ пересказа сделанного и списка файлов — это видно из коммита. Длинный текст
+ оправдан только там, где решение не восстановить из кода (отклонение от
+ прошлого плана, причина нестандартного решения).
- **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для
проекта факты переносятся в тематический документ. Завершённые планы и
исследования — в `docs/archive/`, удаляются через 30 дней; процедура в
diff --git a/docs/plan-seo.md b/docs/plan-seo.md
index b69f4a2..ff62d69 100644
--- a/docs/plan-seo.md
+++ b/docs/plan-seo.md
@@ -4,12 +4,10 @@
> и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug`
> ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3).
>
-> **`noindex, nofollow` выставлен на каждой странице инструмента**
-> (`web/src/routes/tools/[slug]/+page.svelte`) — осознанно, до появления
-> уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и
-> **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают
-> эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо
-> него. Условие снятия и порядок — §4, S1.
+> **`noindex, nofollow` стоит на странице инструмента без текста**
+> (`web/src/routes/tools/[slug]/+page.svelte`) и снимается автоматически, как
+> только у страницы появляется EN-`seo`-блок: индексность следует за контентом
+> (S1h, §4).
>
> **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный
> интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в
@@ -82,18 +80,16 @@
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
- **Три разных артефакта на странице, три разных источника.** `
` для
- выдачи (`seo.metaTitle`, ~55–60 символов **без префикса бренда** — «Crop PNG
- Online - Free Image Cropper, No Upload»: бренд не приносит собственных
- поисковых запросов и съедает треть выдачи), `meta description`
- (`seo.metaDescription`, ~150–160 символов) и H1 (`pages..title`, чистый
- интент: «Crop PNG online»). **Решение по «free / no upload / нет лимитов»:**
- прямо в H1 они не идут — Google не вознаграждает ключи в заголовке,
- переписывает title примерно в 60% случаев, а список выгод в H1 читается как
- сборка ключей. Эти обещания работают в `meta description` (влияет на
- кликабельность) и в первом абзаце. **Форматы в H1 не перечисляем:** широкие
- интенты («crop jpg») закрывает текст первого абзаца, description и FAQ, а не
- заголовок; это же запрещено §5 как doorway-подход. Реестр не трогаем: `title`
- в нём остаётся для каталога и цепочек, SEO-заголовки живут в словаре.
+ выдачи (`seo.metaTitle`, ~55–60 символов **без префикса бренда** — бренд не
+ приносит собственных поисковых запросов и съедает треть выдачи),
+ `meta description` (`seo.metaDescription`, ~150–160 символов) и H1
+ (`pages..title`). **«free / no upload / нет лимитов» не идут в H1:**
+ Google не вознаграждает ключи в заголовке, переписывает title примерно в 60%
+ случаев, а список выгод в H1 читается как сборка ключей — эти обещания
+ работают в description (влияет на кликабельность) и в первом абзаце. **Форматы
+ в H1 не перечисляем:** широкие интенты («crop jpg») закрывает текст,
+ description и FAQ — это же запрещено §5 как doorway-подход. Реестр не трогаем:
+ `title` в нём остаётся для каталога и цепочек, SEO-заголовки живут в словаре.
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
каждому инструменту, с ручной вычиткой.
@@ -114,19 +110,21 @@
инструментов против конкурента: `docs/tools-map.md`.
- EN-тексты в реестре + i18n-словари ru/en.
-Чего нет (пробелы закрываются этапами ниже):
+Чего нет на момент аудита (закрыто этапами §4 — смотри чекбоксы там, здесь
+состояние не дублируется):
-| Пробел | Где сейчас | Этап |
-| -------------------------------------------------- | -------------------------------- | ---- |
-| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
-| `sitemap.xml` | генерируется при сборке (S1b) | — |
-| `robots.txt` без `Sitemap:` | allow-all + `Sitemap:` (S1c) | — |
-| `rel=canonical` | есть, `Seo.svelte` (S1d) | — |
-| OG/Twitter-теги | есть; per-tool картинки — S1e | S1 |
-| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
-| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
-| `llms.txt`, хабы категорий с текстом | нет | S3 |
-| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 |
+| Пробел | Где тогда | Этап |
+| -------------------------------------------------- | ----------------------------------- | ---- |
+| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
+| `sitemap.xml` | нет | S1 |
+| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
+| `rel=canonical` | нет | S1 |
+| OG/Twitter-теги, OG-картинки | нет | S1 |
+| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
+| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
+| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
+| `llms.txt`, хабы категорий с текстом | нет | S3 |
+| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 |
Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она
блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают
@@ -180,24 +178,18 @@
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
**domain-required** (после покупки и DNS-настройки).
-- [x] **[S1h]** Robots-тег на страницах инструментов — **сделано 2026-10-12, но
- не снят, а сделан условным** (`web/src/routes/tools/[slug]/+page.svelte`):
- `index, follow` ставится **тогда и только тогда, когда у страницы есть
- EN-`seo`-блок** (`isPageIndexable()` в
- `web/src/lib/i18n/schema-tool-strings.ts`), иначе `noindex, nofollow`.
- Единого места правки нет: индексность следует за контентом, поэтому
- страницу нельзя забыть при написании текста и нельзя проиндексировать с
- заготовкой. Читается базовый словарь, а не объединённый, — переключение
- посетителя на RU не переворачивает тег. Тот же признак выкидывает
- noindex-страницы из sitemap (`gen-sitemap.mjs`), так что список в sitemap
- и набор индексируемых страниц не могут разойтись. **Страховка от «сломать
- руками»:** контентный гейт вынесен в
- `web/src/lib/i18n/schema-tool-strings.test.ts` — страница с `seo`-блоком
+- [x] **[S1h]** Robots-тег сделан **условным, а не снятым**: `index, follow`
+ ставится тогда и только тогда, когда у страницы есть EN-`seo`-блок, иначе
+ `noindex, nofollow`. Единого места правки нет — индексность следует за
+ контентом, поэтому страницу нельзя забыть при написании текста и нельзя
+ проиндексировать с заготовкой. Тот же признак выкидывает noindex-страницы
+ из sitemap, так что список в sitemap и набор индексируемых страниц не
+ разойдутся. **Страховка от «сломать руками»:** контентный гейт в
+ `web/src/lib/i18n/schema-tool-strings.test.ts` — страница с текстом
обязана иметь ≥250 слов, ≥3 шага how-to, ≥3 FAQ и title/description в
- границах SERP, иначе тест падает до индексации. Проверено на сборке:
- `index, follow` ровно на страницах с текстом, `noindex` на остальных,
- sitemap совпадает с этим набором. **Осталось:** снять `noindex` совсем,
- когда текст появится у всех страниц, которые имеет смысл индексировать.
+ границах SERP, иначе тест падает до индексации. **Осталось:** снять
+ `noindex` совсем, когда текст появится у всех страниц, которые имеет смысл
+ индексировать.
#### S1-free: без домена
@@ -227,35 +219,23 @@
#### S1-domain: свой домен (подключён; §6 R2-cut)
-- [x] **[S1a]** Перевести прод на собственный домен — **сделано 2026-10-11**:
- `BASE_PATH` убран из `deploy.yml`, задан `PUBLIC_SITE_ORIGIN`, добавлен
- `web/static/CNAME`. Редирект `https://<юзер>//…` → корневые URL
- (301) делает сам GitHub Pages при включённом кастомном домене. Делается
- одновременно с S1d (canonical), чтобы Google проиндексировал сразу с
- правильным доменом.
-- [x] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs` — **сделано 2026-10-12**:
- пишет `sitemap.xml` в `build/` после `vite build` (вызов добавлен в
- `pnpm build`). Адреса берутся **из собранного HTML**, а не из импорта
- `PAGES`: список prerender-страниц и есть фактический набор URL, а страницы
- с `noindex` в HTML автоматически исключаются — sitemap не может разойтись
- с тем, что реально задеплоено, и не рекламирует то, что мы просим
- краулеров игнорировать. Проверено: в sitemap попадают `/`, `/list-tools`,
- `/chains`, но не `/kit`, `/pipeline` и страницы инструментов (пока S1h).
- Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME` (тот же домен, что
- публикует GitHub Pages, без третьей копии литерала); опциональный
- `BASE_PATH` учитывается как в `vite.config.ts`.
-- [x] **[S1c]** `robots.txt` — **сделано 2026-10-12**: `Sitemap:` добавлен,
- allow-all сохранён. **Служебный `/kit` закрыт не через `Disallow:`, а
- через `noindex` в HTML** (как `/pipeline`): заблокированный краулером, но
- не помеченный noindex URL Google помечает «проиндексирован, несмотря на
- блокировку в robots.txt» и оставляет старый сниппет. Побочная выгода: тот
- же признак автоматически выкидывает `/kit` из sitemap.
-- [x] **[S1d]** Компонент `Seo.svelte` (kit) — **был сделан до S1b, отмечено
- 2026-10-12**: canonical (абсолютный URL от `PUBLIC_SITE_ORIGIN` +
- `SITE_URL`, `web/src/lib/seo.ts`), OG (title/description/type/url/image) и
- `twitter:card`. Подключён на `/`, `/list-tools`, `/chains`,
- `/tools/[slug]`. Картинка пока общая `og/default.png` — per-tool остаётся
- за S1e.
+- [x] **[S1a]** Перевести прод на собственный домен: `BASE_PATH` убран из
+ `deploy.yml`, задан `PUBLIC_SITE_ORIGIN`, добавлен `web/static/CNAME`.
+ Редирект `https://<юзер>//…` → корневые URL (301) делает сам GitHub
+ Pages при включённом кастомном домене.
+- [x] **[S1b]** `web/scripts/gen-sitemap.mjs` пишет `sitemap.xml` в `build/`
+ после `vite build`. Адреса берутся **из собранного HTML**, а не из импорта
+ `PAGES`: prerender-страницы и есть фактический набор URL, а `noindex`
+ исключается автоматически — sitemap не может разойтись с тем, что
+ задеплоено. Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME`, чтобы
+ не плодить третий литерал домена.
+- [x] **[S1c]** `robots.txt`: `Sitemap:` добавлен, allow-all сохранён.
+ **Служебный `/kit` закрыт через `noindex` в HTML, а не через `Disallow:`**
+ — заблокированный краулером, но не помеченный noindex URL Google помечает
+ «проиндексирован, несмотря на блокировку в robots.txt».
+- [x] **[S1d]** `Seo.svelte` (canonical, OG, `twitter:card`) подключён на `/`,
+ `/list-tools`, `/chains`, `/tools/[slug]`. Картинка пока общая
+ `og/default.png` — per-tool остаётся за S1e.
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
@@ -266,42 +246,25 @@
> `e2e` non-blocking, поэтому его статус читается по отдельному job
> (`docs/decisions.md`, раздел 10).
>
-> **Фактически (2026-10-12) порядок изменился:** S2-контент написан раньше
-> снятия `noindex`, а S1h сделан условным — индексность страницы равна наличию
-> её текста (S1h). Гейт выше в части «`noindex` отсутствует» выполняется для
-> страниц с текстом; полное снятие тега остаётся отдельной задачей.
+> **Фактический порядок другой:** контент S2 написан раньше, а S1h стал
+> условным. Гейт «`noindex` отсутствует» выполняется для страниц с текстом;
+> полное снятие тега остаётся отдельной задачей.
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
-- [x] **[S2a]** Схема данных: `seo`-блок per-tool — **сделано 2026-10-12**: тип
- `SeoContent` (`longDescription`, `howTo: string[]`, `faq: {q, a}[]`) в
- `web/src/lib/i18n/dict.ts`, поле `seo?` в `PageStrings`, доступ
- `pageSeo(page)` в `web/src/lib/i18n/schema-tool-strings.ts`. Хранится в
- словаре `pages..seo`, не в реестре: у реестра нет EN-копии длинного
- текста по определению, а словарь — единственное место, где лежит текст.
- **Пока EN-only:** `ignoreMissingPatterns: ["pages.*.seo"]` в
- `web/eslint.config.js` снимает требование паритета локалей для этого
- блока, RU появится вместе с `/ru/` (§0). Первые тексты — `crop-png`,
- `resize-png`.
-- [x] **[S2b]** Компонент контента страницы инструмента — **сделано 2026-10-12**
- минимальной версией: `web/src/lib/components/PageContent.svelte` рендерит
- `longDescription` абзацем, `howTo` списком ``, `faq` через ``
- (текст в DOM, ответ не требует скролла); подключён в
- `web/src/routes/tools/[slug]/+page.svelte` под рабочей областью
- инструмента и рендерится только при наличии `seo`-блока, так что страницы
- без текста не меняются. Новый токен `--size-content-prose` (мера колонки
- текста) появился вместе с потребителем. **Не сделано и вынесено в S2c:**
- related tools и хлебные крошки.
-- [x] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) и related
- tools — **сделано 2026-10-12**: `Breadcrumbs.svelte` (над H1, первая
- крошка ссылается на `/list-tools`, средняя — текст до появления хабов
- категорий, S3c) и блок Related из 6 карточек под FAQ. **Related выводится,
- а не перечисляется:** `relatedPages()` в `web/src/lib/catalog.ts` берёт
- соседей по категории в порядке каталога и добирает из остального каталога,
- если категория мала (в `text` всего три страницы — это поймал тест).
- Список руками не ведётся, новый инструмент попадает в чужие related сам.
- Тесты: `web/src/lib/catalog.test.ts` (категория, детерминированность,
- достаточность соседей).
+- [x] **[S2a]** Тип `SeoContent` (`longDescription`, `howTo`, `faq`) в
+ `web/src/lib/i18n/dict.ts`, доступ `pageSeo(page)`. Хранится в словаре
+ `pages..seo`, не в реестре: длинного текста в реестре быть не может.
+ EN-only до решения по `/ru/` — паритет локалей снят исключением
+ `ignoreMissingPatterns` в `web/eslint.config.js`.
+- [x] **[S2b]** `web/src/lib/components/PageContent.svelte`: абзац → ``
+ how-to → FAQ в ``, под рабочей областью инструмента. Рендерится
+ только при наличии `seo`-блока, поэтому страницы без текста не меняются.
+- [x] **[S2c]** `Breadcrumbs.svelte` над H1 (средняя крошка — текст до появления
+ хабов категорий, S3c) и блок Related под FAQ. **Related выводится, а не
+ перечисляется:** `relatedPages()` берёт соседей по категории и добирает из
+ каталога, если категория мала; новый инструмент попадает в чужие related
+ сам.
- [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
(приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5. **Ядро
@@ -311,15 +274,13 @@
`change-png-opacity`, `set-alpha-channel-png`, `round-corners-png`,
`invert-colors-png`, `flip-png`, `convert-png-to-webp`, `png-to-bmp`,
`svg-to-png`, `png-to-base64`, `change-aspect-ratio-png`, `add-text-png`.
- Каждая: `title` (H1), `metaTitle` (38–54 симв.), `metaDescription`
- (134–159 симв.), `longDescription`, how-to (3–5 шагов) и 4 FAQ. Текст
- написан по фактической схеме инструмента (параметры реестра), без
- шаблонной подстановки. **Правило набора текста, которое доказало себя на
- пилоте:** H1 — чистый интент, «free / no upload» — в `metaTitle` и
- `metaDescription`, форматы — в первом абзаце и FAQ, ограничения
- инструмента проговариваются прямо («это не AI-удаление фона», «только
- 90/180/270, произвольный угол ресемплит»). Честное ограничение работает
- лучше приписывания. Остальное ядро §5 — тем же шаблоном.
+ Текст написан по фактической схеме инструмента, без подстановки в шаблон.
+ **Правило набора текста:** H1 — чистый интент, «free / no upload» — в
+ `metaTitle` и `metaDescription`, форматы — в первом абзаце и FAQ,
+ ограничения инструмента проговариваются прямо («это не AI-удаление фона»,
+ «только 90/180/270, произвольный угол ресемплит»): честное ограничение
+ отвечает на вопрос People-Also-Ask лучше приписывания. Остальное ядро §5 —
+ тем же шаблоном.
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
рантайме.
@@ -331,13 +292,11 @@
(из тех же крошек, что видны в UI); на главной — `WebSite` +
`Organization`; на каталоге — `BreadcrumbList`. Построители в
`web/src/lib/seo.ts`, вывод — `web/src/lib/components/JsonLd.svelte`.
- Проверено на сборке: три разбора в `build/tools/crop-png.html`, FAQPage с
- 4 вопросами, два узла на главной. **Известная грязь:** в `JsonLd.svelte`
- инлайновый `eslint-disable svelte/no-at-html-tags` — `{@html}`
- единственный способ вставить значение в Svelte, а
- `` не работает (Svelte читает содержимое script как
- raw text). Suppress обоснован экранированием `<` в `\u003c`, но
- пересмотрен не позже: задача в `docs/backlog.md`, раздел «Тесты и lint».
+ **Известная грязь:** в `JsonLd.svelte` инлайновый
+ `eslint-disable svelte/no-at-html-tags` — `` не
+ подставляет значение (Svelte читает содержимое script как raw text),
+ `{@html}` остаётся единственным способом. Suppress обоснован
+ экранированием `<`; пересмотр — задача в `docs/backlog.md`.
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
инструментов с одним предложением на каждый (генерится из реестра тем же
скриптом, что sitemap).
@@ -361,12 +320,14 @@
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
мониторинг индексации и запросов. Бесплатно и обязательно.
-- [ ] **[S4b]** Довести GA4 до решения §0: cookieless-конфигурация, отключённые
- рекламные audiences и signals, `anonymize_ip`, запрет на передачу
- параметров с данными картинки. Мусорные попадания уже отсечены: тег
- подключается только при `PUBLIC_GA_ENABLED=true` (выставлен в
- deploy-workflow, `+layout.svelte`). GA/ЯМ как замена счётчику не
- рассматриваем.
+- [x] **[S4b]** GA4 в cookieless-конфигурации: Consent Mode v2 со всеми `denied`
+ **до** вызова `config`, рекламные signals и audiences выключены,
+ `ip_collection: false`. **Про `anonymize_ip` из исходной формулировки
+ задачи:** в GA4 параметра нет, IP там не сохраняется целиком — поэтому
+ `ip_collection: false`, а не мёртвый флаг. Запрет на данные картинки
+ проверяем: тег нигде, кроме `ga.js`, не вызывается
+ (`web/src/lib/analytics-config.test.ts`). Формулировка «без трекинга» на
+ публичных страницах по-прежнему не используется.
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
подача на alternative.to (страница против конкурента), Product Hunt,
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
@@ -483,24 +444,18 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
Бренд — `easy-image-tools`, домен — `easy-image-tools.com`.
-- [x] **[R1]** Переезд бренда выполнен (2026-10-11): хардкод в компонентах
- убран, бренд — i18n-константа `t("brand")` (точка истины — словари
- `brand` + README). Репозиторий переименован в `easy-image-tools`, remote
- обновлён, привязанные к репо ссылки/пути переключены: `BASE_PATH` в
- `deploy.yml`, GitHub-URL в `TopBar.svelte`, `repository` в `cog.toml`.
- **НЕ трогать:** адреса страниц инструментов (`/tools/crop-png`),
- localStorage-ключи (бренда не содержат), историю `docs/archive`.
-- [x] **[R2]** Домен `easy-image-tools.com` зарегистрирован (2026-10-11);
- `SITE_URL` в `web/src/lib/seo.ts` переключён на него.
-- [x] **[R2-cut]** Прод переведён на домен без `BASE_PATH` (§0) — **сделано
- 2026-10-11**: DNS + HTTPS, `PUBLIC_SITE_ORIGIN` =
- `https://easy-image-tools.com` в `deploy.yml`, `web/static/CNAME`. 301 со
- старого GitHub Pages делает сам GitHub при включённом кастомном домене.
- Canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
-- **Порядок:** R1(код), покупка домена и cutover (R2-cut) сделаны. Осталось
- S1-domain (canonical + sitemap: S1b/S1c/S1d), а S1-domain — блокер для S1h
- (снятие `noindex`). Переименование делали до индексации (до S1h): после снятия
- `noindex` это был бы переезд с 301 и потеря позиций — не делать.
+- [x] **[R1]** Переезд бренда: хардкод убран, бренд — i18n-константа
+ `t("brand")` (точка истины — словари `brand` + README). Репозиторий и
+ remote переименованы, привязанные ссылки/пути переключены. **НЕ трогать:**
+ адреса страниц инструментов (`/tools/crop-png`), localStorage-ключи
+ (бренда не содержат), историю `docs/archive`.
+- [x] **[R2]** Домен `easy-image-tools.com` зарегистрирован; `SITE_URL` в
+ `web/src/lib/seo.ts` переключён на него.
+- [x] **[R2-cut]** Прод на домене без `BASE_PATH` (§0): DNS + HTTPS,
+ `PUBLIC_SITE_ORIGIN` в `deploy.yml`, `web/static/CNAME`. 301 со старого
+ GitHub Pages делает сам GitHub при включённом кастомном домене.
+- **Порядок:** переименование делали до индексации: после снятия `noindex` это
+ был бы переезд с 301 и потеря позиций — не делать.
## 7. Что осознанно НЕ делаем
diff --git a/web/src/lib/analytics-config.test.ts b/web/src/lib/analytics-config.test.ts
new file mode 100644
index 0000000..a945dfc
--- /dev/null
+++ b/web/src/lib/analytics-config.test.ts
@@ -0,0 +1,55 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+// The analytics tag ships as a static file (`web/static/ga.js`) so it can be
+// gated by PUBLIC_GA_ENABLED at build time; these guards cover the promises of
+// docs/plan-seo.md section 0 - cookieless, no ads, and nothing about the image.
+const WEB_DIR = fileURLToPath(new URL("../../", import.meta.url));
+const GA_PATH = join(WEB_DIR, "static", "ga.js");
+
+function sourceFiles(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) return sourceFiles(full);
+ return entry.name.endsWith(".ts") || entry.name.endsWith(".svelte")
+ ? [full]
+ : [];
+ });
+}
+
+describe("GA tag configuration", () => {
+ const ga = readFileSync(GA_PATH, "utf8");
+
+ it("denies analytics and ad storage before the config call", () => {
+ for (const key of [
+ "analytics_storage",
+ "ad_storage",
+ "ad_user_data",
+ "ad_personalization",
+ ]) {
+ expect(ga, key).toMatch(new RegExp(`${key}:\\s*"denied"`));
+ }
+ expect(ga.indexOf('gtag("consent"')).toBeLessThan(
+ ga.indexOf('gtag("config"'),
+ );
+ });
+
+ it("keeps remarketing and Google signals off", () => {
+ expect(ga).toMatch(/allow_google_signals:\s*false/);
+ expect(ga).toMatch(/allow_ad_personalization_signals:\s*false/);
+ });
+
+ it("sends nothing but the page view", () => {
+ const callers = sourceFiles(join(WEB_DIR, "src"))
+ .filter((file) => !file.endsWith(".test.ts"))
+ .filter((file) =>
+ /gtag\s*\(|dataLayer\.push/.test(readFileSync(file, "utf8")),
+ );
+ expect(
+ callers.map((file) => file.replace(WEB_DIR, "")),
+ "gtag is configured in one place only",
+ ).toEqual([]);
+ });
+});
diff --git a/web/static/ga.js b/web/static/ga.js
index 38d4703..9b0fa37 100644
--- a/web/static/ga.js
+++ b/web/static/ga.js
@@ -2,5 +2,26 @@ window.dataLayer = window.dataLayer || [];
function gtag() {
window.dataLayer.push(arguments);
}
+
+// Consent Mode v2, denied by default, before any config call: analytics then
+// run in cookieless mode (pings without cookies, no cross-site identity), which
+// is the configuration promised in `docs/plan-seo.md` section 0. No consent UI
+// is planned - nothing here is granted by default.
+gtag("consent", "default", {
+ analytics_storage: "denied",
+ ad_storage: "denied",
+ ad_user_data: "denied",
+ ad_personalization: "denied",
+ functionality_storage: "denied",
+ personalization_storage: "denied",
+ security_storage: "granted",
+});
+
gtag("js", new Date());
-gtag("config", "G-MEQJ7JHRQQ");
+gtag("config", "G-MEQJ7JHRQQ", {
+ // No remarketing and no ad audiences: this site takes no ads (section 0).
+ allow_google_signals: false,
+ allow_ad_personalization_signals: false,
+ // GA4 drops the full IP either way; this removes the parameter outright.
+ ip_collection: false,
+});
\ No newline at end of file