mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: ga consent mode v2
This commit is contained in:
@@ -55,7 +55,9 @@
|
||||
закрытия.
|
||||
- **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n,
|
||||
UI и рефакторинг.
|
||||
- **Не коммитить.** Коммит и пуш делает разработчик после ревью.
|
||||
- **Не коммитить.** Коммит и пуш делает разработчик после ревью. **И не
|
||||
напоминать про них:** пользователь сам решает, когда коммитить и деплоить, — в
|
||||
отчёте о шаге этого не предлагать.
|
||||
- **Не запускать тяжёлое без спроса.** Playwright — только по явной команде
|
||||
пользователя, один прогон, без циклов повторов и фонового запуска.
|
||||
- **Не писать изменяемые числа** (количество инструментов, тестов, локалей,
|
||||
@@ -102,6 +104,10 @@
|
||||
`completed`, `archived`).
|
||||
- Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и
|
||||
ссылка на канонический документ.
|
||||
- **Отмеченная `[x]` задача — короткая запись:** что решили и почему, без дат,
|
||||
пересказа сделанного и списка файлов — это видно из коммита. Длинный текст
|
||||
оправдан только там, где решение не восстановить из кода (отклонение от
|
||||
прошлого плана, причина нестандартного решения).
|
||||
- **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для
|
||||
проекта факты переносятся в тематический документ. Завершённые планы и
|
||||
исследования — в `docs/archive/`, удаляются через 30 дней; процедура в
|
||||
|
||||
+99
-144
@@ -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.
|
||||
- **Три разных артефакта на странице, три разных источника.** `<title>` для
|
||||
выдачи (`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,15 +110,17 @@
|
||||
инструментов против конкурента: `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 |
|
||||
| `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 |
|
||||
@@ -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. Что осознанно НЕ делаем
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+22
-1
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user