From dad71f0bda3b745735a6de767978f3f5a69aa316 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Mon, 12 Oct 2026 17:32:16 +0500 Subject: [PATCH] feat: ga consent mode v2 --- AGENTS.md | 8 +- docs/plan-seo.md | 253 +++++++++++---------------- web/src/lib/analytics-config.test.ts | 55 ++++++ web/static/ga.js | 23 ++- 4 files changed, 188 insertions(+), 151 deletions(-) create mode 100644 web/src/lib/analytics-config.test.ts 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.<slug>.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.<slug>.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://<юзер>/<repo>/…` → корневые 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://<юзер>/<repo>/…` → корневые 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.<slug>.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` списком `<ol>`, `faq` через `<details>` - (текст в 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.<slug>.seo`, не в реестре: длинного текста в реестре быть не может. + EN-only до решения по `/ru/` — паритет локалей снят исключением + `ignoreMissingPatterns` в `web/eslint.config.js`. +- [x] **[S2b]** `web/src/lib/components/PageContent.svelte`: абзац → `<ol>` + how-to → FAQ в `<details>`, под рабочей областью инструмента. Рендерится + только при наличии `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, а - `<script>{json}</script>` не работает (Svelte читает содержимое script как - raw text). Suppress обоснован экранированием `<` в `\u003c`, но - пересмотрен не позже: задача в `docs/backlog.md`, раздел «Тесты и lint». + **Известная грязь:** в `JsonLd.svelte` инлайновый + `eslint-disable svelte/no-at-html-tags` — `<script>{json}</script>` не + подставляет значение (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