Compare commits

...
8 Commits
27 changed files with 1491 additions and 121 deletions
+7 -1
View File
@@ -55,7 +55,9 @@
закрытия. закрытия.
- **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n, - **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n,
UI и рефакторинг. UI и рефакторинг.
- **Не коммитить.** Коммит и пуш делает разработчик после ревью. - **Не коммитить.** Коммит и пуш делает разработчик после ревью. **И не
напоминать про них:** пользователь сам решает, когда коммитить и деплоить, — в
отчёте о шаге этого не предлагать.
- **Не запускать тяжёлое без спроса.** Playwright — только по явной команде - **Не запускать тяжёлое без спроса.** Playwright — только по явной команде
пользователя, один прогон, без циклов повторов и фонового запуска. пользователя, один прогон, без циклов повторов и фонового запуска.
- **Не писать изменяемые числа** (количество инструментов, тестов, локалей, - **Не писать изменяемые числа** (количество инструментов, тестов, локалей,
@@ -102,6 +104,10 @@
`completed`, `archived`). `completed`, `archived`).
- Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и - Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и
ссылка на канонический документ. ссылка на канонический документ.
- **Отмеченная `[x]` задача — короткая запись:** что решили и почему, без дат,
пересказа сделанного и списка файлов — это видно из коммита. Длинный текст
оправдан только там, где решение не восстановить из кода (отклонение от
прошлого плана, причина нестандартного решения).
- **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для - **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для
проекта факты переносятся в тематический документ. Завершённые планы и проекта факты переносятся в тематический документ. Завершённые планы и
исследования — в `docs/archive/`, удаляются через 30 дней; процедура в исследования — в `docs/archive/`, удаляются через 30 дней; процедура в
+20
View File
@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - - - - -
## [v0.12.0](https://github.com/Ku6epXBOCTuK/easy-image-tools/compare/1981afbc641807a2e95a4fecd109f94f00024b67..v0.12.0) - 2026-10-12
#### Features
- ga consent mode v2 - ([dad71f0](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/dad71f0bda3b745735a6de767978f3f5a69aa316)) - Ku6epXBOCTuK
- add json-ld - ([1981afb](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/1981afbc641807a2e95a4fecd109f94f00024b67)) - Ku6epXBOCTuK
- - -
## [v0.11.0](https://github.com/Ku6epXBOCTuK/easy-image-tools/compare/399c3fb282687811c5047f4cee5299a00e3ec8a8..v0.11.0) - 2026-10-12
#### Features
- breadcrumbs, related tools - ([399c3fb](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/399c3fb282687811c5047f4cee5299a00e3ec8a8)) - Ku6epXBOCTuK
- - -
## [v0.10.0](https://github.com/Ku6epXBOCTuK/easy-image-tools/compare/0e6a8464aff985e6304c2657ce8c750bcb73e40f..v0.10.0) - 2026-10-12
#### Features
- seo descriptions for 20 core tools, index follows content - ([0b30eef](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/0b30eef16cd32bda942c8bceb6b4b39adcaf146b)) - Ku6epXBOCTuK
- initial seo descriptions texts - ([984f71c](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/984f71c8224c3cbb90941dfad0baae63918c0730)) - Ku6epXBOCTuK
- - -
## [v0.9.0](https://github.com/Ku6epXBOCTuK/easy-image-tools/compare/b7ba1f501fc704b699dec67d46e69d6892432a38..v0.9.0) - 2026-10-12 ## [v0.9.0](https://github.com/Ku6epXBOCTuK/easy-image-tools/compare/b7ba1f501fc704b699dec67d46e69d6892432a38..v0.9.0) - 2026-10-12
#### Features #### Features
- update brand name, add domain - ([b7ba1f5](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/b7ba1f501fc704b699dec67d46e69d6892432a38)) - Ku6epXBOCTuK - update brand name, add domain - ([b7ba1f5](https://github.com/Ku6epXBOCTuK/easy-image-tools/commit/b7ba1f501fc704b699dec67d46e69d6892432a38)) - Ku6epXBOCTuK
+18 -5
View File
@@ -10,7 +10,7 @@
## Тесты и lint ## Тесты и lint
- [ ] правило - только английские комментарии не работает на svelte файлы - [x] правило - только английские комментарии не работает на svelte файлы
- [ ] правило - ограницить комментарии вроде уменьшило простыни из комментариев, - [ ] правило - ограницить комментарии вроде уменьшило простыни из комментариев,
но всё равно они есть и часто пишет очевидные вещи. надо более строгое но всё равно они есть и часто пишет очевидные вещи. надо более строгое
правило. может такой вариант: разрешено писать комментарии в коде, но правило. может такой вариант: разрешено писать комментарии в коде, но
@@ -34,6 +34,16 @@
правило такое (запретить toContain без i18n) правило такое (запретить toContain без i18n)
- [ ] посмотреть ошибки e2e, разрешить запускать е2е без ручного подтверждения, - [ ] посмотреть ошибки e2e, разрешить запускать е2е без ручного подтверждения,
и рассмотреть - может уже пора включить их в ci\cd гейт и рассмотреть - может уже пора включить их в ci\cd гейт
- [ ] пересмотреть `{@html}` в `web/src/lib/components/JsonLd.svelte`: сейчас
там инлайновый `eslint-disable-next-line svelte/no-at-html-tags`, потому
что `<script>{json}</script>` не подставляет значение (Svelte читает
содержимое script как raw text — это видно по ошибке «'json' is assigned a
value but never used»), а другой способ вставки разметки в Svelte нет.
Варианты к рассмотрению: исключение для файла в `web/eslint.config.js`
вместо suppress'а в коде; генерация JSON-LD на этапе сборки рядом с
sitemap (`web/scripts/gen-sitemap.mjs`); отказ от компонента. Критерий
закрытия: в production-коде нет `eslint-disable`, и разметка продолжает
попадать в собранный HTML
## Pipeline ## Pipeline
@@ -230,13 +240,16 @@
## SEO/GEO ## SEO/GEO
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и - [ ] исследовать - стоит сделать страницу категорий? для crumbs
- [ ] related tools - после добавления популярности инструментов учитывать самые
популярные инструменты
- [x] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4 контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по - [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
вордстату с уникальным контентом; редиректы только для опечаток и вордстату с уникальным контентом; редиректы только для опечаток и
переименований уже опубликованных страниц; клонов с canonical не делать переименований уже опубликованных страниц; клонов с canonical не делать
(`docs/plan-seo.md` §5) (`docs/plan-seo.md` §5)
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c) - [x] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
- [ ] Seo.svelte: per-tool OG-картинки (S1e) — у каждой страницы инструмента - [ ] Seo.svelte: per-tool OG-картинки (S1e) — у каждой страницы инструмента
своя превью-карточка 1200×630 для соцсетей/мессенджеров: имя инструмента, своя превью-карточка 1200×630 для соцсетей/мессенджеров: имя инструмента,
бренд, иконка. Сейчас все страницы ссылаются на общую бренд, иконка. Сейчас все страницы ссылаются на общую
@@ -246,10 +259,10 @@
инструмента в `static/og/[slug].png`, в `Seo.svelte` подставлять og-URL по инструмента в `static/og/[slug].png`, в `Seo.svelte` подставлять og-URL по
slug страницы. Критерий приёмки: у `/tools/flip-png` в og:image своя slug страницы. Критерий приёмки: у `/tools/flip-png` в og:image своя
картинка с названием инструмента, файл лежит в `static/og/` картинка с названием инструмента, файл лежит в `static/og/`
- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2) - [x] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3) - [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c) - [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
- [ ] GSC: подключить домен, отправить sitemap, мониторинг индексации (S4a) - [x] GSC: подключить домен, отправить sitemap, мониторинг индексации (S4a)
- [ ] GA4: cookieless-конфигурация, отключить рекламные audiences и signals, - [ ] GA4: cookieless-конфигурация, отключить рекламные audiences и signals,
`anonymize_ip` — сейчас в `web/src/routes/+layout.svelte` голый `anonymize_ip` — сейчас в `web/src/routes/+layout.svelte` голый
`gtag('config')` (S4b) `gtag('config')` (S4b)
+119 -94
View File
@@ -4,12 +4,10 @@
> и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug` > и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug`
> ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3). > ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3).
> >
> **`noindex, nofollow` выставлен на каждой странице инструмента** > **`noindex, nofollow` стоит на странице инструмента без текста**
> (`web/src/routes/tools/[slug]/+page.svelte`) — осознанно, до появления > (`web/src/routes/tools/[slug]/+page.svelte`) и снимается автоматически, как
> уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и > только у страницы появляется EN-`seo`-блок: индексность следует за контентом
> **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают > (S1h, §4).
> эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо
> него. Условие снятия и порядок — §4, S1.
> >
> **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный > **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный
> интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в > интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в
@@ -81,6 +79,17 @@
открытия страницы. **Рекламу и ремаркетинг не ставим** — они противоречат открытия страницы. **Рекламу и ремаркетинг не ставим** — они противоречат
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга» позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b. на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
- **Три разных артефакта на странице, три разных источника.** `<title>` для
выдачи (`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. Контент — по - **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
каждому инструменту, с ручной вычиткой. каждому инструменту, с ручной вычиткой.
@@ -101,19 +110,21 @@
инструментов против конкурента: `docs/tools-map.md`. инструментов против конкурента: `docs/tools-map.md`.
- EN-тексты в реестре + i18n-словари ru/en. - EN-тексты в реестре + i18n-словари ru/en.
Чего нет (пробелы закрываются этапами ниже): Чего нет на момент аудита (закрыто этапами §4 — смотри чекбоксы там, здесь
состояние не дублируется):
| Пробел | Где сейчас | Этап | | Пробел | Где тогда | Этап |
| -------------------------------------------------- | -------------------------------- | ---- | | -------------------------------------------------- | ----------------------------------- | ---- |
| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 | | `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
| `sitemap.xml` | генерируется при сборке (S1b) | — | | `sitemap.xml` | нет | S1 |
| `robots.txt` без `Sitemap:` | allow-all + `Sitemap:` (S1c) | — | | `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
| `rel=canonical` | есть, `Seo.svelte` (S1d) | — | | `rel=canonical` | нет | S1 |
| OG/Twitter-теги | есть; per-tool картинки — S1e | S1 | | OG/Twitter-теги, OG-картинки | нет | S1 |
| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 | | Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 | | Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
| `llms.txt`, хабы категорий с текстом | нет | S3 | | JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 | | `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 |
Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она
блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают
@@ -167,14 +178,18 @@
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
**domain-required** (после покупки и DNS-настройки). **domain-required** (после покупки и DNS-настройки).
- [ ] **[S1h]** Снять `noindex, nofollow` со страниц инструментов - [x] **[S1h]** Robots-тег сделан **условным, а не снятым**: `index, follow`
(`web/src/routes/tools/[slug]/+page.svelte`) — заменить на ставится тогда и только тогда, когда у страницы есть EN-`seo`-блок, иначе
`<meta name="robots" content="index, follow" />`. **Это первым `noindex, nofollow`. Единого места правки нет — индексность следует за
обязательным шагом S1:** пока тег стоит, остальные задачи S1 готовят контентом, поэтому страницу нельзя забыть при написании текста и нельзя
инфраструктуру, но не приводят трафик. Порядок: S1h ставится после проиндексировать с заготовкой. Тот же признак выкидывает noindex-страницы
S1b/S1c/S1d, иначе Google проиндексирует сайт без sitemap и canonical. из sitemap, так что список в sitemap и набор индексируемых страниц не
Снимать `noindex` до готовности S2-контента осознанно не стоит: в индекс разойдутся. **Страховка от «сломать руками»:** контентный гейт в
попадут тонкие страницы с одним title/description. `web/src/lib/i18n/schema-tool-strings.test.ts` — страница с текстом
обязана иметь ≥250 слов, ≥3 шага how-to, ≥3 FAQ и title/description в
границах SERP, иначе тест падает до индексации. **Осталось:** снять
`noindex` совсем, когда текст появится у всех страниц, которые имеет смысл
индексировать.
#### S1-free: без домена #### S1-free: без домена
@@ -204,35 +219,23 @@
#### S1-domain: свой домен (подключён; §6 R2-cut) #### S1-domain: свой домен (подключён; §6 R2-cut)
- [x] **[S1a]** Перевести прод на собственный домен — **сделано 2026-10-11**: - [x] **[S1a]** Перевести прод на собственный домен: `BASE_PATH` убран из
`BASE_PATH` убран из `deploy.yml`, задан `PUBLIC_SITE_ORIGIN`, добавлен `deploy.yml`, задан `PUBLIC_SITE_ORIGIN`, добавлен `web/static/CNAME`.
`web/static/CNAME`. Редирект `https://<юзер>/<repo>/…` → корневые URL Редирект `https://<юзер>/<repo>/…` → корневые URL (301) делает сам GitHub
(301) делает сам GitHub Pages при включённом кастомном домене. Делается Pages при включённом кастомном домене.
одновременно с S1d (canonical), чтобы Google проиндексировал сразу с - [x] **[S1b]** `web/scripts/gen-sitemap.mjs` пишет `sitemap.xml` в `build/`
правильным доменом. после `vite build`. Адреса берутся **из собранного HTML**, а не из импорта
- [x] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs` — **сделано 2026-10-12**: `PAGES`: prerender-страницы и есть фактический набор URL, а `noindex`
пишет `sitemap.xml` в `build/` после `vite build` (вызов добавлен в исключается автоматически — sitemap не может разойтись с тем, что
`pnpm build`). Адреса берутся **из собранного HTML**, а не из импорта задеплоено. Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME`, чтобы
`PAGES`: список prerender-страниц и есть фактический набор URL, а страницы не плодить третий литерал домена.
с `noindex` в HTML автоматически исключаются — sitemap не может разойтись - [x] **[S1c]** `robots.txt`: `Sitemap:` добавлен, allow-all сохранён.
с тем, что реально задеплоено, и не рекламирует то, что мы просим **Служебный `/kit` закрыт через `noindex` в HTML, а не через `Disallow:`**
краулеров игнорировать. Проверено: в sitemap попадают `/`, `/list-tools`, — заблокированный краулером, но не помеченный noindex URL Google помечает
`/chains`, но не `/kit`, `/pipeline` и страницы инструментов (пока S1h). «проиндексирован, несмотря на блокировку в robots.txt».
Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME` (тот же домен, что - [x] **[S1d]** `Seo.svelte` (canonical, OG, `twitter:card`) подключён на `/`,
публикует GitHub Pages, без третьей копии литерала); опциональный `/list-tools`, `/chains`, `/tools/[slug]`. Картинка пока общая
`BASE_PATH` учитывается как в `vite.config.ts`. `og/default.png` — per-tool остаётся за S1e.
- [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.
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h** > **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в > (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
@@ -242,32 +245,58 @@
> `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job > `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job
> `e2e` non-blocking, поэтому его статус читается по отдельному job > `e2e` non-blocking, поэтому его статус читается по отдельному job
> (`docs/decisions.md`, раздел 10). > (`docs/decisions.md`, раздел 10).
>
> **Фактический порядок другой:** контент S2 написан раньше, а S1h стал
> условным. Гейт «`noindex` отсутствует» выполняется для страниц с текстом;
> полное снятие тега остаётся отдельной задачей.
### S2. Контентный слой (параллельно quality-плану, волнами по категориям) ### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`, - [x] **[S2a]** Тип `SeoContent` (`longDescription`, `howTo`, `faq`) в
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде `web/src/lib/i18n/dict.ts`, доступ `pageSeo(page)`. Хранится в словаре
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка `pages.<slug>.seo`, не в реестре: длинного текста в реестре быть не может.
вручную пачками по категориям (самый трудоёмкий этап: объём растёт вместе EN-only до решения по `/ru/` — паритет локалей снят исключением
с числом инструментов × 2 локали; RU — отложить до решения о `/ru/`). `ignoreMissingPatterns` в `web/eslint.config.js`.
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to - [x] **[S2b]** `web/src/lib/components/PageContent.svelte`: абзац → `<ol>`
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка how-to → FAQ в `<details>`, под рабочей областью инструмента. Рендерится
внутри категории + 2–3 «соседних» интента). только при наличии `seo`-блока, поэтому страницы без текста не меняются.
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI. - [x] **[S2c]** `Breadcrumbs.svelte` над H1 (средняя крошка — текст до появления
хабов категорий, S3c) и блок Related под FAQ. **Related выводится, а не
перечисляется:** `relatedPages()` берёт соседей по категории и добирает из
каталога, если категория мала; новый инструмент попадает в чужие related
сам.
- [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра - [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
(приоритет по вордстату: transparent png, jpg→png, compress, resize, crop, (приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5. rotate, circle crop, add text, pixelate, grayscale…); отбор — §5. **Ядро
закрыто: 20 страниц из §5** — `crop-png`, `resize-png`,
`remove-background-png`, `convert-png-to-jpg`, `pixelate-png`,
`grayscale-png`, `rotate-png`, `create-empty-png`, `linear-gradient-png`,
`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`.
Текст написан по фактической схеме инструмента, без подстановки в шаблон.
**Правило набора текста:** H1 — чистый интент, «free / no upload» — в
`metaTitle` и `metaDescription`, форматы — в первом абзаце и FAQ,
ограничения инструмента проговариваются прямо («это не AI-удаление фона»,
«только 90/180/270, произвольный угол ресемплит»): честное ограничение
отвечает на вопрос People-Also-Ask лучше приписывания. Остальное ядро §5 —
тем же шаблоном.
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1 - Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
рантайме. рантайме.
### S3. Структурированные данные + GEO ### S3. Структурированные данные + GEO
- [ ] **[S3a]** JSON-LD: `SoftwareApplication` (+ `offers` price 0) и `FAQPage` - [x] **[S3a]** JSON-LD — на странице инструмента `SoftwareApplication`
на страницах инструментов; `WebSite` + `Organization` на главной; (`offers` price 0), `FAQPage` (из уже написанного FAQ) и `BreadcrumbList`
`BreadcrumbList` на всех внутренних. Учесть: Google ограничил rich results (из тех же крошек, что видны в UI); на главной — `WebSite` +
по FAQ/HowTo для мелких сайтов — разметка нужна прежде всего для `Organization`; на каталоге — `BreadcrumbList`. Построители в
LLM-парсинга, она безвредна. `web/src/lib/seo.ts`, вывод — `web/src/lib/components/JsonLd.svelte`.
**Известная грязь:** в `JsonLd.svelte` инлайновый
`eslint-disable svelte/no-at-html-tags` — `<script>{json}</script>` не
подставляет значение (Svelte читает содержимое script как raw text),
`{@html}` остаётся единственным способом. Suppress обоснован
экранированием `<`; пересмотр — задача в `docs/backlog.md`.
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список - [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
инструментов с одним предложением на каждый (генерится из реестра тем же инструментов с одним предложением на каждый (генерится из реестра тем же
скриптом, что sitemap). скриптом, что sitemap).
@@ -291,12 +320,14 @@
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap, - [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
мониторинг индексации и запросов. Бесплатно и обязательно. мониторинг индексации и запросов. Бесплатно и обязательно.
- [ ] **[S4b]** Довести GA4 до решения §0: cookieless-конфигурация, отключённые - [x] **[S4b]** GA4 в cookieless-конфигурации: Consent Mode v2 со всеми `denied`
рекламные audiences и signals, `anonymize_ip`, запрет на передачу **до** вызова `config`, рекламные signals и audiences выключены,
параметров с данными картинки. Мусорные попадания уже отсечены: тег `ip_collection: false`. **Про `anonymize_ip` из исходной формулировки
подключается только при `PUBLIC_GA_ENABLED=true` (выставлен в задачи:** в GA4 параметра нет, IP там не сохраняется целиком — поэтому
deploy-workflow, `+layout.svelte`). GA/ЯМ как замена счётчику не `ip_collection: false`, а не мёртвый флаг. Запрет на данные картинки
рассматриваем. проверяем: тег нигде, кроме `ga.js`, не вызывается
(`web/src/lib/analytics-config.test.ts`). Формулировка «без трекинга» на
публичных страницах по-прежнему не используется.
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами), - [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
подача на alternative.to (страница против конкурента), Product Hunt, подача на alternative.to (страница против конкурента), Product Hunt,
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции. тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
@@ -413,24 +444,18 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
Бренд — `easy-image-tools`, домен — `easy-image-tools.com`. Бренд — `easy-image-tools`, домен — `easy-image-tools.com`.
- [x] **[R1]** Переезд бренда выполнен (2026-10-11): хардкод в компонентах - [x] **[R1]** Переезд бренда: хардкод убран, бренд — i18n-константа
убран, бренд — i18n-константа `t("brand")` (точка истины — словари `t("brand")` (точка истины — словари `brand` + README). Репозиторий и
`brand` + README). Репозиторий переименован в `easy-image-tools`, remote remote переименованы, привязанные ссылки/пути переключены. **НЕ трогать:**
обновлён, привязанные к репо ссылки/пути переключены: `BASE_PATH` в адреса страниц инструментов (`/tools/crop-png`), localStorage-ключи
`deploy.yml`, GitHub-URL в `TopBar.svelte`, `repository` в `cog.toml`. (бренда не содержат), историю `docs/archive`.
**НЕ трогать:** адреса страниц инструментов (`/tools/crop-png`), - [x] **[R2]** Домен `easy-image-tools.com` зарегистрирован; `SITE_URL` в
localStorage-ключи (бренда не содержат), историю `docs/archive`. `web/src/lib/seo.ts` переключён на него.
- [x] **[R2]** Домен `easy-image-tools.com` зарегистрирован (2026-10-11); - [x] **[R2-cut]** Прод на домене без `BASE_PATH` (§0): DNS + HTTPS,
`SITE_URL` в `web/src/lib/seo.ts` переключён на него. `PUBLIC_SITE_ORIGIN` в `deploy.yml`, `web/static/CNAME`. 301 со старого
- [x] **[R2-cut]** Прод переведён на домен без `BASE_PATH` (§0) — **сделано GitHub Pages делает сам GitHub при включённом кастомном домене.
2026-10-11**: DNS + HTTPS, `PUBLIC_SITE_ORIGIN` = - **Порядок:** переименование делали до индексации: после снятия `noindex` это
`https://easy-image-tools.com` в `deploy.yml`, `web/static/CNAME`. 301 со был бы переезд с 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 и потеря позиций — не делать.
## 7. Что осознанно НЕ делаем ## 7. Что осознанно НЕ делаем
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "easy-image-tools", "name": "easy-image-tools",
"version": "0.9.0", "version": "0.12.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+4 -1
View File
@@ -102,7 +102,10 @@
проверяются. проверяются.
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не - `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
проверяются на missing; в текущей конфигурации это неиспользуемые проверяются на missing; в текущей конфигурации это неиспользуемые
`tools.*.params`. Эта опция не отключает empty/placeholder checks. `tools.*.params` и `pages.*.seo` — длинный текст страниц инструмента
(SEO-блок, `docs/plan-seo.md` S2a) пишется только на индексируемой EN-локали,
RU придёт вместе с префиксом `/ru/`. Эта опция не отключает empty/placeholder
checks.
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в - Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в `__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
+1 -1
View File
@@ -164,7 +164,7 @@ export default tseslint.config(
"warn", "warn",
{ {
baseLocaleFallback: ["pages.*.title", "pages.*.description"], baseLocaleFallback: ["pages.*.title", "pages.*.description"],
ignoreMissingPatterns: ["tools.*.params"], ignoreMissingPatterns: ["tools.*.params", "pages.*.seo"],
}, },
], ],
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.9.0", "version": "0.12.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+2
View File
@@ -162,6 +162,8 @@
--size-parts-grid-min: 120px; --size-parts-grid-min: 120px;
--size-workspace-min: 260px; --size-workspace-min: 260px;
--size-content-max-wide: 1680px; --size-content-max-wide: 1680px;
--size-content-prose: 1120px;
--size-content-measure: 84ch;
--font-size-s: 12px; --font-size-s: 12px;
--font-size-m: 14px; --font-size-m: 14px;
+55
View File
@@ -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([]);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { PREVIEW_GROUPS, PREVIEW_TOTAL, relatedPages } from "./catalog";
import { PAGES } from "./registry";
describe("preview catalog", () => {
it("groups every page exactly once", () => {
expect(PREVIEW_GROUPS.flatMap((group) => group.pages)).toHaveLength(
PAGES.length,
);
expect(PREVIEW_TOTAL).toBe(PAGES.length);
});
});
describe("relatedPages", () => {
it("returns same-category pages first, without the page itself", () => {
for (const page of PAGES) {
const related = relatedPages(page, 4);
expect(related.every((r) => r.slug !== page.slug)).toBe(true);
const firstOtherCategory = related.findIndex(
(r) => r.category !== page.category,
);
expect(
related
.slice(0, firstOtherCategory < 0 ? undefined : firstOtherCategory)
.every((r) => r.category === page.category),
`${page.slug}: category neighbors must come first`,
).toBe(true);
}
});
it("respects the requested count", () => {
for (const page of PAGES) {
expect(relatedPages(page, 3).length).toBeLessThanOrEqual(3);
}
});
it("is deterministic (same input, same neighbors)", () => {
for (const page of PAGES) {
expect(relatedPages(page, 4)).toEqual(relatedPages(page, 4));
}
});
it("gives enough neighbors for the related block", () => {
const short = PAGES.filter((page) => relatedPages(page, 6).length < 3);
expect(
short.map((page) => page.slug),
"pages with fewer than 3 related tools",
).toEqual([]);
});
});
+18
View File
@@ -15,3 +15,21 @@ export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
})).filter((group) => group.pages.length > 0); })).filter((group) => group.pages.length > 0);
export const PREVIEW_TOTAL = PAGES.length; export const PREVIEW_TOTAL = PAGES.length;
/**
* Neighbors for the related block: same category in catalog order, topped up
* from the whole catalog because the `text` category alone holds too few.
* Derived, never a hand-maintained list, so a new tool joins its neighbors.
*/
export function relatedPages(page: Page, count: number): Page[] {
const same = (
PREVIEW_GROUPS.find((g) => g.id === page.category)?.pages ?? []
).filter((candidate) => candidate.slug !== page.slug);
if (same.length >= count) return same.slice(0, count);
const others = PAGES.filter(
(candidate) =>
candidate.slug !== page.slug && candidate.category !== page.category,
);
return [...same, ...others].slice(0, count);
}
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import type { Page } from "$lib/registry";
import { ChevronRight } from "@lucide/svelte";
interface Props {
page: Page;
}
let { page }: Props = $props();
const categoryLabel = $derived(t(`categories.${page.category}`));
const title = $derived(pageTitle(page));
</script>
<!-- Middle crumb is plain text: a category has no address of its own until the
category hubs land (docs/plan-seo.md S3c), then it becomes a link here. -->
<nav class="crumbs" aria-label={t("toolPage.breadcrumbAria")}>
<a href={resolve("/list-tools")}>{t("toolPage.breadcrumbCatalog")}</a>
<ChevronRight size={13} aria-hidden="true" />
<span>{categoryLabel}</span>
<ChevronRight size={13} aria-hidden="true" />
<span class="current" aria-current="page">{title}</span>
</nav>
<style>
.crumbs {
display: flex;
align-items: center;
gap: var(--space-s);
flex-wrap: wrap;
max-width: var(--size-content-max-wide);
padding: var(--space-xxxl) clamp(var(--space-m), 4vw, var(--space-xxxl)) 0;
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
}
a {
color: var(--color-text-muted);
text-decoration: none;
}
a:hover {
color: var(--color-main);
}
.current {
color: var(--color-text);
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
interface Props {
data: Record<string, unknown> | Record<string, unknown>[];
}
let { data }: Props = $props();
// `<` is escaped so a value containing markup can never close the script tag
// early; U+2028/U+2029 are escaped because they break JS string literals.
const json = $derived(
JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/[\u2028\u2029]/g, (c) =>
c === "\u2028" ? "\\u2028" : "\\u2029",
),
);
</script>
<!-- The closing tag is built from two parts: a literal `</script>` inside the
template would end the block the parser is reading. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -- json is escaped above, the only markup is this script wrapper -->
{@html `<script type="application/ld+json">${json}</` + "script>"}
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import { relatedPages } from "$lib/catalog";
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import {
pageDescription,
pageSeo,
pageTitle,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import type { Page } from "$lib/registry";
import { toolIcon } from "$lib/tool-icons";
interface Props {
page: Page;
}
let { page }: Props = $props();
const seo = $derived(pageSeo(page));
const related = $derived(relatedPages(page, 6));
</script>
<!-- Below the tool, not above it: the workspace is what the visitor came for,
and the copy is here to rank and to answer questions without a scroll. -->
{#if seo}
<section class="content">
<p class="long">{seo.longDescription}</p>
<h2>{t("toolPage.howToHeading")}</h2>
<ol class="steps">
{#each seo.howTo as step (step)}
<li>{step}</li>
{/each}
</ol>
<h2>{t("toolPage.faqHeading")}</h2>
<div class="faq">
{#each seo.faq as item (item.q)}
<details>
<summary>{item.q}</summary>
<p>{item.a}</p>
</details>
{/each}
</div>
<!-- Internal links: the only way a crawler reaches the neighbours of
this page, so the block stays derived from the category, never a
hand-picked list. -->
<h2>{t("toolPage.relatedHeading")}</h2>
<div class="related">
{#each related as item (item.slug)}
<ToolCard
title={pageTitle(item)}
slug={item.slug}
description={pageDescription(item)}
icon={toolIcon(item.slug)}
/>
{/each}
</div>
</section>
{/if}
<style>
.content {
max-width: var(--size-content-prose);
margin: 0 auto var(--space-xxl);
padding: clamp(var(--space-xl), 4vw, var(--space-xxxl))
clamp(var(--space-m), 4vw, var(--space-xxxl));
border: var(--size-border) solid var(--color-border);
background: var(--color-panel);
}
.long {
max-width: var(--size-content-measure);
margin: 0 0 var(--space-xxl);
color: var(--color-text);
font-size: var(--font-size-l);
line-height: 1.65;
}
h2 {
margin: var(--space-xxxl) 0 var(--space-l);
font-size: var(--font-size-xl);
line-height: 1.2;
color: var(--color-text);
}
.steps {
max-width: var(--size-content-measure);
margin: 0;
padding-left: var(--space-xxl);
display: flex;
flex-direction: column;
gap: var(--space-m);
color: var(--color-text);
font-size: var(--font-size-m);
line-height: 1.6;
}
.faq {
display: flex;
flex-direction: column;
border-top: var(--size-border) solid var(--color-border);
}
details {
border-bottom: var(--size-border) solid var(--color-border);
}
summary {
padding: var(--space-l) 0;
color: var(--color-text);
font-size: var(--font-size-m);
font-weight: 600;
cursor: pointer;
}
details p {
margin: 0 0 var(--space-l);
max-width: var(--size-content-measure);
color: var(--color-text-muted);
font-size: var(--font-size-m);
line-height: 1.6;
}
.related {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-l);
}
@media (--bp-tablet) {
.long {
font-size: var(--font-size-m);
}
.related {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define"; import { ButtonVariantDefine } from "$lib/components/ui/define";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte";
import { DragDropProvider } from "@dnd-kit/svelte";
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { cachedDataUrl } from "$lib/core/io"; import { cachedDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import { import {
chainStepTitle, chainStepTitle,
chainWarningNotice, chainWarningNotice,
@@ -17,8 +17,8 @@
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { moveStep, type ChainStep } from "$lib/pipeline.svelte"; import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
import { getTool, type Page, type Tool } from "$lib/registry"; import { getTool, type Page, type Tool } from "$lib/registry";
import ChipButton from "$lib/components/ui/ChipButton.svelte"; import { DragDropProvider } from "@dnd-kit/svelte";
import Toggle from "$lib/components/ui/Toggle.svelte"; import { Star } from "@lucide/svelte";
import AddStepButton from "./AddStepButton.svelte"; import AddStepButton from "./AddStepButton.svelte";
import PartsGrid from "./PartsGrid.svelte"; import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
@@ -32,8 +32,8 @@
import { createChainHost } from "./chain-host.svelte"; import { createChainHost } from "./chain-host.svelte";
import { createOutputControls } from "./output-controls.svelte"; import { createOutputControls } from "./output-controls.svelte";
import { hasPreviewResult } from "./schema-preview-model"; import { hasPreviewResult } from "./schema-preview-model";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import type { DisplayError } from "./schema-tool-runner.svelte"; import type { DisplayError } from "./schema-tool-runner.svelte";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import { import {
createSchemaToolState, createSchemaToolState,
toolSchemaOf, toolSchemaOf,
@@ -483,8 +483,8 @@
<style> <style>
.schema-tool { .schema-tool {
padding: calc(var(--space-xxxl) + var(--space-l)) padding: var(--space-l) clamp(var(--space-m), 4vw, var(--space-xxxl))
clamp(var(--space-m), 4vw, var(--space-xxxl)); calc(var(--space-xxxl) + var(--space-l));
flex: 1; flex: 1;
} }
.header { .header {
+24 -1
View File
@@ -16,10 +16,33 @@ export function isLocale(value: unknown): value is Locale {
); );
} }
/** Page strings (`pages.<slug>`): title and description keyed by slug. */ /**
* Long-form copy of a tool page (`pages.<slug>.seo`), rendered under the tool
* UI. EN only for now: EN is the indexable locale, RU gets its copy when the
* `/ru/` prefix and hreflang land (`docs/plan-seo.md`, section 0).
*/
export type SeoContent = {
/**
* SERP `<title>` (~55-60 chars, no brand prefix: the brand only earns a
* query of its own). Falls back to the brand + page title.
*/
metaTitle: string;
/** SERP meta description (~150-160 chars): the claim that wins the click. */
metaDescription: string;
/** Direct definition of what the tool does; GEO answers in the first paragraph. */
longDescription: string;
/** Manual steps; the array order is the reading order of the `<ol>`. */
howTo: string[];
/** FAQ pairs; reused verbatim by the FAQPage JSON-LD (S3a). */
faq: { q: string; a: string }[];
};
/** Page strings (`pages.<slug>`): title, description, and long-form content. */
export type PageStrings = { export type PageStrings = {
title?: string; title?: string;
description?: string; description?: string;
/** Absent on pages whose copy is not written yet. */
seo?: SeoContent;
}; };
/** Tool strings (`tools.<id>`): field labels, verdict keys. */ /** Tool strings (`tools.<id>`): field labels, verdict keys. */
+691 -1
View File
@@ -50,6 +50,11 @@ export const en: Dict = {
stepHeading: "Step {n}", stepHeading: "Step {n}",
removeStepAria: "Remove step", removeStepAria: "Remove step",
stepError: "Step {n} ({title}): {msg}", stepError: "Step {n} ({title}): {msg}",
howToHeading: "How to use it",
faqHeading: "Questions",
relatedHeading: "Related tools",
breadcrumbAria: "Breadcrumb",
breadcrumbCatalog: "All tools",
}, },
chain: { chain: {
addStep: "Add step", addStep: "Add step",
@@ -392,7 +397,692 @@ export const en: Dict = {
}, },
// Page titles/descriptions are not duplicated in the base locale: the EN // Page titles/descriptions are not duplicated in the base locale: the EN
// string from the page registry (`web/src/lib/registry/pages/`) is used. // string from the page registry (`web/src/lib/registry/pages/`) is used.
pages: {}, // The `seo` copy is the exception: it has no registry counterpart and
// exists only in the dictionary.
pages: {
"crop-png": {
title: "Crop PNG online",
seo: {
metaTitle: "Crop PNG Online - Free Image Cropper, No Upload",
metaDescription:
"Crop a PNG right in your browser: choose the area, set the size, download the result. Free, no upload, no signup. Works with PNG, JPG, WebP and BMP.",
longDescription:
"Crop PNG cuts a rectangular area out of an image and keeps only what you selected. Everything happens inside your browser tab: the file is decoded, cut and re-encoded on your device, so it is never uploaded, never stored on a server and never visible to anyone else. The upload accepts PNG, JPG, WebP and BMP, and the Download button writes the result back as PNG, JPG, WebP or BMP. Coordinates are pixels counted from the top-left corner, and the selected area is clipped to the image — a rectangle that hangs over the edge is cut down to the part that actually exists, and the result tile says so when that happens.",
howTo: [
"Drop an image into the source area, or pick a file from your device.",
"Set X and Y to the top-left corner of the area you want to keep.",
"Set Width and Height to the size of that area; both default to the full image.",
"The preview redraws as you type, and values beyond the image edge are clipped for you.",
"Pick the output format in the Download button and save the result.",
],
faq: [
{
q: "Does cropping a PNG reduce its quality?",
a: "No. Cropping copies the pixels of the selected area exactly as they are — no resampling and no re-compression inside the tool. If you export to JPG or WebP, the encoder of that format applies its own compression, and the quality slider in the Download button controls how much.",
},
{
q: "Are my images uploaded to a server?",
a: "No. Decoding, cutting and encoding all run in the page you have open. The tool sends no request with your file, needs no account, has no daily limit, and is free for commercial use. It is open source, so the claim can be checked in the code.",
},
{
q: "Can I crop a JPG or a WebP, not only a PNG?",
a: "Yes — PNG, JPG, WebP and BMP are all accepted. The page address keeps the png name because that is the most searched spelling of this tool, but the tool itself does not care about formats.",
},
{
q: "What happens if the crop area goes outside the image?",
a: "The area is clipped to the intersection with the image, so the result is never bigger than the original. When the output ends up smaller than the requested rectangle, a note on the result tile tells you the area was clamped.",
},
],
},
},
"resize-png": {
title: "Resize PNG online",
seo: {
metaTitle: "Resize PNG Online - Free Image Resizer, No Upload",
metaDescription:
"Resize PNG images in your browser and keep the aspect ratio. Free, no upload, no limits, no signup. Works with PNG, JPG, WebP and BMP.",
longDescription:
"Resize PNG scales an image to new pixel dimensions with bilinear interpolation, which keeps edges smooth when the picture is enlarged or reduced. The scaling runs locally in your browser, so the original file is never uploaded and never leaves your device. The upload takes PNG, JPG, WebP and BMP, and you choose the output format in the Download button. With the aspect ratio kept, one side defines the scale and the other follows; if you set both sides, the image is fitted inside them, so it is scaled down until it fits rather than stretched.",
howTo: [
"Drop an image into the source area, or pick a file from your device.",
"Enter the new width or height; with aspect kept the other side is recalculated for you.",
"Set both sides only when you want the image to fit inside a fixed box.",
"Check the preview: the dimensions under the tile show the real output size.",
"Choose PNG, JPG, WebP or BMP in the Download button and save.",
],
faq: [
{
q: "Does resizing make the image blurry?",
a: "Enlarging adds pixels that were not in the original, so fine detail softens — that is unavoidable at any tool. Bilinear interpolation keeps the transition smooth instead of blocky. For photos that must stay sharp when enlarged, generate or upscale at a higher source resolution instead.",
},
{
q: "Will the aspect ratio change?",
a: "Not while the aspect ratio is kept: set one side and the other follows the original ratio. Set both sides and the image is scaled until it fits inside them, which keeps the ratio but may leave the result smaller than the box.",
},
{
q: "Are my images uploaded anywhere?",
a: "No. Resizing runs in your browser with no server involved — no upload, no account, no daily limit, free for commercial use, and open source.",
},
{
q: "Can I resize a JPG or WebP instead of a PNG?",
a: "Yes. PNG, JPG, WebP and BMP are accepted on upload, and the output format is yours to choose in the Download button, including a quality slider for the lossy formats.",
},
],
},
},
"remove-background-png": {
title: "Remove background from PNG",
seo: {
metaTitle: "Remove PNG Background - Free, No Upload",
metaDescription:
"Remove a solid background from a PNG and get a transparent image in your browser. Pick a color, set the tolerance, smooth the edges. Free, no upload.",
longDescription:
"Remove background from a PNG makes every pixel of one chosen color transparent, so the subject is left standing on an empty canvas. Pick the background color, widen the tolerance until the whole backdrop disappears, and smooth the boundary if the cut edge looks hard. The result keeps the original resolution and the untouched color of the subject, and it is produced entirely in your browser: the file is decoded, processed and re-encoded on your own device, so nothing is uploaded and nothing is kept on a server. PNG, JPG, WebP and BMP are accepted on upload, and the transparent result can be saved as PNG, JPG, WebP or BMP from the Download button.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Set Background color to the color of the backdrop you want to get rid of.",
"Raise Tolerance until the background disappears; lower it if the subject starts losing parts.",
"Keep Outer regions on when the same color appears inside the subject - it protects the inside.",
"Raise Smooth to soften the cut edge, then save the result from the Download button.",
],
faq: [
{
q: "Is this AI background removal?",
a: "No. This is a color-based cut: it removes pixels close to the color you name, not a trained model guessing at objects. That is why it is instant and works offline, and also why it needs a background of roughly one solid color - for hair, fur or busy scenes use a dedicated AI matting tool.",
},
{
q: "Why do parts of my subject stay in the background color?",
a: "Tolerance compares each pixel with the color you picked by distance. Anti-aliased edges and shadows differ from the flat backdrop, so they fall outside a narrow tolerance. Raise it gradually, and switch Outer regions off if the same color appears inside the subject and you do want it removed.",
},
{
q: "Does removing the background reduce image quality?",
a: "No resampling and no re-compression of the subject happens: the pixels are either kept or set to fully transparent. Only the export format matters - a lossy format such as JPG will smooth the new transparent edge once it is composited onto a background.",
},
{
q: "Does it work with JPG and WebP, not only PNG?",
a: "Yes. PNG, JPG, WebP and BMP can all be loaded, and after the cut you can save the transparent result as PNG, JPG, WebP or BMP. Note that JPG cannot store transparency, so a transparent area exported to JPG is filled with the background color.",
},
],
},
},
"convert-png-to-jpg": {
title: "Convert PNG to JPG",
seo: {
metaTitle: "Convert PNG to JPG - Free Converter, No Upload",
metaDescription:
"Convert PNG to JPG right in your browser. Transparent areas are filled with the background color you pick, and you set the JPEG quality. Free, no upload.",
longDescription:
"Converting a PNG to JPG makes a smaller file that any device and program can open, at the cost of the two things JPG cannot do: transparency and lossless pixels. Everything is done locally in your browser, so your image is never uploaded and never stored anywhere. Pick the background color that transparent areas are filled with - white by default, which is what most white-backed documents expect - set the JPEG quality, and download. The result carries no watermark, no account requirement and no daily limit.",
howTo: [
"Drop the PNG into the source area, or pick a file from your device.",
"Set Background to the color that transparent parts should be filled with.",
"Raise or lower Quality: 90 keeps the picture clean, 70-80 makes a visibly smaller file.",
"Check the preview - the dimensions under the tile show the output size.",
"Download the .jpg file from the Download button.",
],
faq: [
{
q: "What happens to transparency when converting to JPG?",
a: "JPG has no alpha channel, so transparent pixels cannot be stored. Each of them is composited onto the background color you chose - white unless you pick another one. Pick white for screenshots on white, or your brand color when the picture will sit on a colored page.",
},
{
q: "Does converting to JPG make the image worse?",
a: "JPG is lossy: it keeps the picture close to the original but throws away detail the eye rarely notices. At quality 90 the difference is hard to see and the file is several times smaller. If you need not to lose a single pixel, keep the PNG and compress it instead.",
},
{
q: "Why is my JPG bigger than I expected?",
a: "Quality and size pull against each other. Dropping from 90 to 75 usually halves the file with barely visible change; below 60 blocking and ringing around sharp edges become obvious. When the goal is a specific file size rather than the best picture, use the size limit in the Download button.",
},
{
q: "Will the result have a watermark?",
a: "No. There is no watermark, no signup and no upload - the file is encoded in your browser and saved straight to your device. The tool is free for commercial use and open source.",
},
],
},
},
"pixelate-png": {
title: "Pixelate PNG",
seo: {
metaTitle: "Pixelate PNG - Free Mosaic Filter, No Upload",
metaDescription:
"Pixelate a PNG in your browser: every block is averaged into a single color, the classic mosaic effect. Choose the block size from 2 to 64 px. Free, no upload.",
longDescription:
"Pixelating an image hides detail by replacing every square block with one averaged color, which produces the blocky mosaic that covers faces, hides screens or simply reads as a retro effect. The block size slider runs from 2 to 64 pixels: small blocks give a fine grain, large blocks give an unmistakable mosaic. The dimensions of the picture do not change, so a pixelated result can still be used wherever the original size is expected. Like every tool here, it runs inside your browser - the file is never uploaded, and PNG, JPG, WebP and BMP are all accepted.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Set Block size to a small value for a fine grain or a large one for a heavy mosaic.",
"Watch the preview: each block becomes a single averaged color.",
"Save the result as PNG, JPG, WebP or BMP from the Download button.",
],
faq: [
{
q: "What block size should I pick?",
a: "For covering a face, a block of roughly 1/20 to 1/12 of the smaller side works well - about 16 to 32 px on a 400 px image. Small images need small blocks: at 2 px the effect is a soft texture rather than a mosaic.",
},
{
q: "Is pixelating the same as blurring?",
a: "No. Blur averages neighbouring pixels smoothly, while pixelating replaces each block with one flat color and keeps hard square edges. That is what makes the result read as mosaic rather than as an out-of-focus picture.",
},
{
q: "Can I undo the effect?",
a: "Yes - move the block size back to the minimum or reload the original file. Nothing is destructive: the source image stays in the browser and the effect is re-applied on every change of the slider.",
},
{
q: "Does it work with JPG and WebP, not only PNG?",
a: "Yes. PNG, JPG, WebP and BMP are accepted on upload, and the pixelated result can be saved in any of them. A transparent PNG keeps its transparency after the blocks are averaged.",
},
],
},
},
"grayscale-png": {
title: "Convert PNG to grayscale",
seo: {
metaTitle: "Grayscale PNG Converter - Free, No Upload",
metaDescription:
"Turn a PNG into shades of gray with the standard BT.601 luminance formula. Transparency is preserved. Free, no upload, no settings, runs in your browser.",
longDescription:
"Converting an image to grayscale replaces every color with a shade of gray, computed from the standard BT.601 luminance formula that matches human brightness perception, so a red pixel becomes the same gray a camera would render rather than a flat average of its channels. Transparency is left untouched, which means a logo keeps its clean edges instead of gaining a gray box around it. There are no settings to tune - upload, read the result, download - and the whole thing happens in your browser, with your file never leaving the device.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Read the preview: the colors are already replaced by shades of gray.",
"Save the result as PNG, JPG, WebP or BMP from the Download button.",
],
faq: [
{
q: "What is the difference between grayscale and black and white?",
a: "Grayscale keeps a continuous range of grays, so the image still looks like the original - just without color. Black and white reduces everything to exactly two values with a sharp threshold, which flattens photos. If you need the two-value version, run the black-and-white tool afterwards.",
},
{
q: "Does grayscale keep transparency?",
a: "Yes. Only the color channels are converted; the alpha channel is copied as it is, so a transparent PNG stays transparent and the edges of a cut-out logo stay clean.",
},
{
q: "Is the conversion lossless?",
a: "Within the tool, yes: it is a per-pixel channel calculation, not a compression, so no detail is thrown away. The output format still matters - save as PNG or WebP to keep it lossless, and remember JPG has no transparency.",
},
{
q: "Are my images uploaded to convert them?",
a: "No. The conversion runs in your browser tab, with no upload, no account, no daily limit and no watermark. The tool is free for commercial use and open source.",
},
],
},
},
"rotate-png": {
title: "Rotate PNG",
seo: {
metaTitle: "Rotate PNG - Free Image Rotator, No Upload",
metaDescription:
"Rotate a PNG by 90, 180 or 270 degrees in your browser. Lossless, no quality loss, no upload of your file. Free, no signup, no limits.",
longDescription:
"Rotating a PNG turns the picture by a quarter turn or by half a turn. Because the angle is a multiple of 90 degrees, the pixels are only moved to new positions: nothing is resampled, nothing is interpolated and no quality is lost, which is not true of arbitrary-angle rotation. The canvas grows to the new orientation, so a portrait shot rotated becomes landscape and nothing gets cropped. It all happens in your browser - the file is never uploaded, so screenshots, scans and private photos can be turned without leaving your device.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Pick the angle: 90 degrees clockwise turns it a quarter turn, 180 flips it upside down, 270 turns it the other way.",
"Check the preview: the dimensions under the tile swap for portrait and landscape.",
"Download the result from the Download button in any of the four formats.",
],
faq: [
{
q: "Can I rotate by an angle that is not 90, 180 or 270?",
a: "Not here, on purpose. An arbitrary angle resamples every pixel, which softens edges and can cut the corners off. The three right-angle options move pixels only, so the result is bit-for-bit as sharp as the source.",
},
{
q: "Does rotating lose quality or blur the image?",
a: "No. The rotation is a pure remapping of pixel positions, exactly as rotating a physical photo on the scanner glass would. There is no interpolation step and no re-compression inside the tool.",
},
{
q: "Will part of my image be cut off?",
a: "No. The canvas is resized to the new orientation, so the full picture survives the turn. Width and height simply swap when you rotate by 90 or 270 degrees.",
},
{
q: "How do I flip the image instead of rotating it?",
a: "Use the flip tool on this site: it mirrors the picture horizontally or vertically, which is what you want for a mirrored selfie or a scanned page. Rotation turns the picture, flipping turns it inside out.",
},
],
},
},
"create-empty-png": {
title: "Create empty PNG",
seo: {
metaTitle: "Create Empty PNG - Blank Image Generator, No Upload",
metaDescription:
"Create a blank PNG of any size in your browser: fully transparent or filled with a solid color. No signup, no limits, generated and downloaded instantly.",
longDescription:
"Creating an empty PNG gives you a blank canvas of exactly the size you ask for, either fully transparent or filled with one solid color. Nothing is uploaded because there is nothing to upload: the canvas is generated on your device from the width, height, opacity and color you set, and the file is encoded and saved straight to your machine. Use it as a backdrop for a mockup, as a transparent layer in a design tool, as a spacer in a layout, or as a placeholder that has the exact dimensions your project expects. Sizes run from 1 to 20000 pixels on each side, with presets for the common formats.",
howTo: [
"Set Width and Height in pixels, or pick one of the presets such as a square or a screen size.",
"Leave Opacity at 0 for a fully transparent canvas, or raise it to fill with the chosen color.",
"Pick the fill color if you want a solid background instead of transparency.",
"Download the generated PNG from the Download button.",
],
faq: [
{
q: "How do I make a transparent PNG?",
a: "Set Opacity to 0. Every pixel is then written with a fully transparent alpha channel, which is exactly what a transparent PNG is. Raising the slider fills the canvas with the fill color instead; partial values give a semi-transparent sheet.",
},
{
q: "What size can the canvas be?",
a: "From 1 to 20000 pixels per side, with presets for the usual targets. Very large canvases take a moment to generate because every pixel is filled - a 20000 by 20000 canvas is 400 million pixels.",
},
{
q: "Is the file really a valid PNG?",
a: "Yes. The canvas is encoded as a real PNG by the browser, not a renamed blank file, so it opens in any image viewer and works as a source image for the other tools on this site.",
},
{
q: "Do I have to sign up or upload anything?",
a: "No account, no upload, no daily limit, no watermark. The canvas is created and encoded in your browser, and the tool is free for commercial use and open source.",
},
],
},
},
"linear-gradient-png": {
title: "Create gradient PNG",
seo: {
metaTitle: "Create Gradient PNG - Free Linear Gradient Generator",
metaDescription:
"Generate a linear gradient PNG in your browser: two colors, a direction and any canvas size. Free, no upload, no signup, downloads instantly.",
longDescription:
"A linear gradient is a smooth transition from one color to another along a straight line, and this page draws one as a PNG of any size. Choose the start and end colors, set the angle, and the canvas is filled with the blend in your browser - there is no upload, because there is no source file: the gradient is computed pixel by pixel and encoded into a PNG you can download. Gradients are the background of most landing pages, slide decks and app shells, and exporting one as an image gives you a background that does not depend on CSS support at all.",
howTo: [
"Set the canvas size in pixels, or pick a preset.",
"Choose the color the gradient starts from and the color it ends at.",
"Set the angle: 0 runs left to right, 90 runs bottom to top.",
"Download the gradient as a PNG from the Download button.",
],
faq: [
{
q: "What does the angle mean?",
a: "It is the direction of the gradient line in degrees. At 0 the first color is on the left and the last on the right; at 90 the first color is at the bottom; 180 is right to left and 270 top to bottom.",
},
{
q: "Can I get a radial gradient instead?",
a: "Not on this page - it draws linear gradients. For a radial fade, create a transparent canvas, run the remove-background tool against one edge, and raise the tolerance until the fade covers the image.",
},
{
q: "Does the PNG get banded?",
a: "The blend is computed per pixel in floating point and encoded with a high bit depth, so smooth transitions stay smooth. Very wide low-contrast gradients can still show faint steps after the PNG is quantized, which is a property of the format, not of the generator.",
},
{
q: "Is this free to use in commercial work?",
a: "Yes. There is no signup, no watermark and no daily limit, no file is uploaded, and the project is open source.",
},
],
},
},
"change-png-opacity": {
title: "Change PNG opacity",
seo: {
metaTitle: "Change PNG Opacity - Make Image Transparent, No Upload",
metaDescription:
"Reduce the opacity of a PNG in your browser: the alpha channel is multiplied by a percentage, so 0% is invisible and 100% is unchanged. Free, no upload.",
longDescription:
"Changing the opacity of a PNG scales its alpha channel, so a fully opaque image becomes semi-transparent and a 0 percent result is completely invisible. The color channels are left exactly as they were, which is what makes the difference between making an image translucent and making it faded: nothing is blended, every pixel keeps its own color. The result stays a PNG with a real alpha channel, so it still drops onto a page, a slide or another image without a white box around it. Everything runs in your browser, so the file is never uploaded.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Set the opacity percentage: 100 leaves it untouched, 50 makes it half transparent.",
"Watch the preview against the checkerboard background.",
"Download the result as PNG, JPG, WebP or BMP from the Download button.",
],
faq: [
{
q: "What is the difference between changing opacity and adding a white background?",
a: "Opacity touches only the alpha channel, so the picture itself stays pure and keeps its edges. A white background paints over the transparent parts and leaves the pixels underneath untouched - the picture can still look washed out when it sits on another background.",
},
{
q: "Does making an image transparent reduce its quality?",
a: "No. The alpha value is multiplied per pixel; the color data is copied without resampling. Only the export format matters: save as PNG or WebP to keep transparency, and remember JPG flattens it onto a color.",
},
{
q: "Can I make only part of the image transparent?",
a: "Not here - this page scales the whole alpha channel. To fade one region, remove that background color first and then apply the opacity to the result.",
},
{
q: "Are my images uploaded?",
a: "No. The alpha channel is multiplied in your browser, with no upload, no account and no watermark. It is free for commercial use and open source.",
},
],
},
},
"set-alpha-channel-png": {
title: "Set alpha channel of PNG",
seo: {
metaTitle: "Set PNG Alpha Channel - Uniform Opacity, No Upload",
metaDescription:
"Give every pixel of a PNG the same opacity, or restore full opacity to a faded image. One percentage, colors untouched. Free, runs in your browser.",
longDescription:
"Setting the alpha channel writes one and the same opacity into every pixel of the image, while the colors stay untouched. That is different from scaling the existing transparency: a logo that is already half transparent at the edges becomes uniformly 50 percent everywhere after this operation, and a picture someone flattened to a single low opacity can be brought back to solid. It is the fastest way to make a whole PNG translucent, or to repair an image whose alpha channel was flattened. The file is processed in your browser and never uploaded.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Set the percentage: 100 makes every pixel fully opaque, 50 makes them all half transparent.",
"Check the preview - the checkerboard shows how much transparency is left.",
"Save the result as PNG or WebP from the Download button to keep the alpha channel.",
],
faq: [
{
q: "How is this different from changing image opacity?",
a: "Changing opacity multiplies the existing alpha, so the differences between more and less transparent pixels are preserved. Setting the alpha channel overwrites it: every pixel ends up with exactly the same value. Use multiply when you want to keep the picture's own transparency, and overwrite when you need it uniform.",
},
{
q: "Can this restore a faded PNG?",
a: "Yes, if the image still has an alpha channel and only the values are low: set 100 percent and every pixel becomes fully opaque again. If transparency was already flattened into a solid background color, no alpha channel is left to restore - the pixels are just background-colored.",
},
{
q: "Will the colors change?",
a: "No. Only the alpha value is written; the red, green and blue channels are copied byte for byte.",
},
{
q: "Does it work offline?",
a: "Yes. Once the page is loaded the processing is entirely local, so the tool works without a connection and your file is never uploaded anywhere.",
},
],
},
},
"round-corners-png": {
title: "Round PNG corners",
seo: {
metaTitle: "Round Corners PNG - Free Rounded Corner Tool",
metaDescription:
"Round the corners of a PNG in your browser: the radius is a percentage of half the smaller side, so it scales with the image. Free, no upload, no signup.",
longDescription:
"Rounding the corners of a PNG clips the four corners of the picture and leaves the rest of the pixels untouched, producing the rounded rectangle used by avatars, app icons and cards. The radius is given as a percentage of half the smaller side, so a single value behaves the same on a thumbnail and on a poster - 50 percent is the full circle, and at that point the result becomes a round avatar. No pixel is resampled, and the clipped corner becomes fully transparent instead of being painted over, so the shape stays correct on any background.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Set Radius as a percentage of half the smaller side: 10 is a subtle rounding, 50 a circle.",
"Watch the preview - the clipped corners become transparent.",
"Download the result as PNG, JPG, WebP or BMP from the Download button.",
],
faq: [
{
q: "How do I get a perfect circle?",
a: "Set the radius to 50 percent. The clip is then a circle inscribed in the shorter side, and the rectangular parts outside it become transparent, leaving a round avatar.",
},
{
q: "Why is the radius a percentage and not pixels?",
a: "A percentage of half the smaller side keeps the proportion identical on any image size, so one value works for a whole set of icons and avatars. If you need an exact pixel radius, round an image whose size makes the percentage land where you want it, or clip it further afterwards.",
},
{
q: "Does rounding blur the corner?",
a: "No. Nothing is scaled or resampled - the pixels outside the clip are made transparent, and everything inside stays exactly as it was.",
},
{
q: "Can I round only some of the corners?",
a: "This page rounds all four at once. For a single corner, chain the circular or square mask tool after it, or crop the image so that only the corner you want is left.",
},
],
},
},
"invert-colors-png": {
title: "Invert PNG colors",
seo: {
metaTitle: "Invert PNG Colors - Free Negative Image Tool",
metaDescription:
"Invert the colors of a PNG in your browser: every channel becomes 255 minus its value, like photographic film negative. Alpha stays unchanged. Free, no upload.",
longDescription:
"Inverting an image replaces every color channel with its opposite, so white becomes black, a blue sky turns orange and the whole picture looks like a photographic negative. The operation is arithmetic - each channel is mapped to 255 minus its value - and the alpha channel is deliberately left alone, so transparent regions stay transparent instead of turning into an opaque block. It is instant and lossless: no resampling, no re-compression, just a per-pixel subtraction that happens in your browser with no upload of your file.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"The colors invert as soon as the image is loaded - there are no settings.",
"Save the inverted result as PNG, JPG, WebP or BMP from the Download button.",
],
faq: [
{
q: "Why are my transparent areas not inverted?",
a: "The alpha channel is not touched, only the colors. A fully transparent pixel keeps alpha 0 no matter what its color values are, so a transparent PNG stays transparent and does not turn into a solid inverted block.",
},
{
q: "Does inverting reduce image quality?",
a: "No. Inverting is a reversible per-channel operation, not a filter: the result inverted twice is identical to the original. Only the export format can introduce loss, so save as PNG or WebP for a byte-exact negative.",
},
{
q: "What is this called in Photoshop?",
a: "The same thing: Image > Adjustments > Invert, or a Negate adjustment layer in GIMP. Mathematically it is 255 minus each channel value.",
},
{
q: "Does the tool upload my image?",
a: "No. The conversion runs in your browser tab, with no server, no account and no watermark. It is free for commercial use and open source.",
},
],
},
},
"flip-png": {
title: "Flip PNG",
seo: {
metaTitle: "Flip PNG - Mirror Image Horizontally, No Upload",
metaDescription:
"Mirror a PNG in your browser: flip it horizontally or vertically with no quality loss and no cropping. Free, no upload, no signup, instant.",
longDescription:
"Flipping mirrors an image along one axis: horizontally turns left into right, vertically turns top into bottom. The pixels are only moved to mirrored positions - nothing is rotated, scaled or resampled, and the canvas keeps its size, so a flipped image is exactly as sharp as the original. It is the quick fix for a selfie taken with a mirrored preview, a scanned page that came out backwards, or a design mockup that needs the opposite direction.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Choose the axis: Horizontal for left to right, Vertical for top to bottom.",
"Switch between the two to compare - the preview updates instantly.",
"Download the result from the Download button.",
],
faq: [
{
q: "What is the difference between flip and mirror?",
a: "They are the same operation, named differently. Flipping horizontally mirrors the picture left to right, which is what corrects a photo taken with a mirrored camera preview.",
},
{
q: "Does flipping lose quality?",
a: "No. Each pixel keeps its exact value and only its position changes, so the result is as sharp as the source. This is unlike rotating by an arbitrary angle, which has to resample the whole image.",
},
{
q: "Will text end up backwards?",
a: "Only if the text was part of the picture - a horizontal flip mirrors letters as well. Turn the text layer off or apply the flip before drawing text onto the image.",
},
{
q: "Can I flip both axes at once?",
a: "Yes - run the tool twice. Flipping horizontally and then vertically is the same as a 180 degree rotation, and it costs nothing but a second click.",
},
],
},
},
"convert-png-to-webp": {
title: "Convert PNG to WebP",
seo: {
metaTitle: "Convert PNG to WebP - Free Converter, No Upload",
metaDescription:
"Convert PNG to WebP in your browser with a quality slider. Transparency is preserved and the file gets much smaller. Free, no upload, no signup.",
longDescription:
"WebP is the modern image format that usually beats PNG on size while still storing transparency. Converting a PNG to WebP here re-encodes the picture in your browser with a quality slider: at high quality the difference is hard to see and the file is noticeably smaller, and at lower quality the savings grow while detail starts to go. Because WebP supports an alpha channel, everything transparent stays transparent - unlike JPG. Nothing is uploaded, there is no watermark, no account and no daily limit.",
howTo: [
"Drop the PNG into the source area, or pick a file from your device.",
"Raise or lower Quality: around 80-90 is the sweet spot between size and detail.",
"Check the preview - transparency is kept, so the checkerboard still shows through.",
"Download the .webp file from the Download button.",
],
faq: [
{
q: "Is WebP better than PNG?",
a: "For photos and screenshots on the web, usually yes: WebP reaches a similar picture at a noticeably smaller file. PNG stays better for flat graphics with few colors, for files you have to edit again, and for tools that do not accept WebP yet.",
},
{
q: "Does converting to WebP lose the transparency?",
a: "No. WebP has an alpha channel, so transparent areas stay transparent. This is the main practical reason to pick WebP over JPG when a PNG has transparency.",
},
{
q: "Which quality value should I choose?",
a: "Between 80 and 90 for most images. Below about 60 the encoder starts smoothing fine detail and text edges; the difference in file size between 95 and 100 is usually not worth the re-encoding.",
},
{
q: "Can I convert WebP back to PNG?",
a: "Open any WebP in this site - the upload accepts WebP - and pick PNG in the Download button. No separate converter is needed for the direction you already have a file for.",
},
],
},
},
"png-to-bmp": {
title: "Convert PNG to BMP",
seo: {
metaTitle: "Convert PNG to BMP - Free Converter, No Upload",
metaDescription:
"Convert PNG to BMP in your browser. The result is a 24-bit bitmap for Windows and old software. Free, no upload, no signup, no watermark.",
longDescription:
"BMP is the uncompressed bitmap format that Windows, office software and a lot of older engineering and printing software still expect. Converting a PNG to BMP writes a plain 24-bit bitmap: every pixel is stored as is, with no compression and no quality loss from the encoding step itself. The catch is alpha - a 24-bit BMP has no transparency, so transparent parts of your PNG are flattened onto black when they are saved this way. The conversion runs in your browser, so the source file is never uploaded.",
howTo: [
"Drop the PNG into the source area, or pick a file from your device.",
"Preview the result; transparent areas are flattened onto black.",
"Download the .bmp file from the Download button.",
],
faq: [
{
q: "What happens to transparency when saving as BMP?",
a: "The output is a 24-bit bitmap, which has no alpha channel, so transparent pixels are written as opaque black. If you need transparency to survive, export to PNG or WebP instead.",
},
{
q: "Why is my BMP so much bigger than the PNG?",
a: "BMP is uncompressed: every pixel is stored literally, so a 1000 by 1000 picture takes about 3 MB regardless of how simple it looks. PNG compresses the same image down to a fraction of that, which is why BMP survived only where compatibility matters more than size.",
},
{
q: "Is the conversion lossless?",
a: "The pixels are copied unchanged - BMP does not compress and does not subsample colors. What you lose compared to the source PNG is only the alpha channel and, if the source was scaled before, whatever resampling already did.",
},
{
q: "Which programs open BMP?",
a: "Essentially all of them: Windows itself, most image editors, office suites and printing software. That reliability is the only reason to choose BMP over PNG or WebP.",
},
],
},
},
"svg-to-png": {
title: "Convert SVG to PNG",
seo: {
metaTitle: "SVG to PNG - Free Converter, No Upload",
metaDescription:
"Rasterize an SVG into a PNG in your browser: paste the markup, set the size, download the bitmap. No upload of files, no account, no watermark.",
longDescription:
"SVG is vector markup: it stores shapes, paths and text, and the browser draws it at whatever size the layout asks for. Some programs do not draw SVG at all, so an icon or a chart sometimes has to be handed over as a bitmap. This page pastes the markup, rasterizes it into a PNG of the size you set, and lets you download it. The SVG is rendered by the browser engine on your machine - no file is uploaded, and no external asset referenced by the SVG is fetched from a server either.",
howTo: [
"Paste the SVG code into the text field on the left.",
"Set the output size in pixels; the drawing is scaled to fit.",
"Check the preview of the rasterized result.",
"Download the PNG from the Download button.",
],
faq: [
{
q: "Will the PNG look the same as the SVG?",
a: "Yes, because the same browser engine that renders SVG on a web page renders it here. Curves, strokes, gradients and fonts supported by the browser are reproduced; features that only exist in a desktop editor are not part of SVG in the first place.",
},
{
q: "Which size should I export?",
a: "Export at the size it will be displayed, or at 2x that for a high-density screen. SVG scales without limit, so a bigger export costs file size but stays sharp.",
},
{
q: "What happens to fonts?",
a: "Text is rendered with the fonts available to the browser. If the SVG does not embed its font and relies on a family you do not have, the preview shows a fallback - convert such text to outlines before rasterizing if the exact shapes matter.",
},
{
q: "Is my SVG uploaded anywhere?",
a: "No. The markup is rasterized in your browser tab, so the code never reaches a server. The same applies to any image the SVG references by URL: it is not fetched for you.",
},
],
},
},
"png-to-base64": {
title: "Convert PNG to Base64",
seo: {
metaTitle: "PNG to Base64 - Free Encoder, No Upload",
metaDescription:
"Encode a PNG as a Base64 string in your browser and copy it into CSS, HTML or JSON. No upload, no account, no limits, and the data never leaves the page.",
longDescription:
"Base64 is the text encoding that turns binary image data into a string safe to paste into source code: a CSS background, an HTML attribute, a JSON config or an email template. Encoding a PNG here produces that string in the result panel, with a button to copy it to the clipboard. The image is read in your browser and encoded there, so the picture and the resulting data never leave your device - which matters because a Base64 string is the image itself, and uploading it would be uploading your file. The result can be used as a data URI directly.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Wait for the string to appear in the result panel.",
"Press the copy button to put the whole string on the clipboard.",
"Use it in CSS as url(data:image/png;base64,...) or paste it where you need.",
],
faq: [
{
q: "Why is the string longer than the file?",
a: "Base64 stores six bits per character, so it costs about 33 percent more characters than the raw bytes: a 300 KB PNG becomes roughly 400 KB of text. It is a transport format, not a compression one.",
},
{
q: "Should I embed images as Base64?",
a: "For small assets - icons, sprites, logos, a few kilobytes - yes, it removes a request and guarantees the image travels with the file. For photos and anything large it usually hurts: the text is bigger, it cannot be cached separately and it blocks parsing.",
},
{
q: "Is the string the whole image?",
a: "Yes, which is why this page never uploads anything. A Base64 string of your picture is as sensitive as the picture itself, so keep it on your machine.",
},
{
q: "What is the difference between Base64 and a data URI?",
a: "A data URI is the full string you put in an attribute: the prefix with the MIME type, then the Base64 payload. Copy the data URI variant when you need the ready-made value for src or background-image.",
},
],
},
},
"change-aspect-ratio-png": {
title: "Change PNG aspect ratio",
seo: {
metaTitle: "Change Image Aspect Ratio - Crop or Pad, No Upload",
metaDescription:
"Fit a PNG into another aspect ratio: crop the center to fill the frame, or pad it with transparency. 1:1, 4:3, 16:9 and more. Free, no upload.",
longDescription:
"Putting an image into a different aspect ratio means one of two things, and the difference matters: either you fill the new frame and lose the overflowing edges, or you keep the whole picture and fill the rest. This page offers both, for the usual ratios - 1:1 for avatars and covers, 4:3 and 3:2 for photos, 16:9 and 9:16 for video and stories. Crop to fill cuts the center, pad to fit adds a transparent border instead. Processing happens in your browser, so the file is never uploaded.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Pick the target ratio: 1:1, 4:3, 3:4, 3:2, 2:3, 16:9 or 9:16.",
"Choose Crop to fill to keep the frame edge to edge, or Pad to fit to keep every pixel.",
"Download the result from the Download button.",
],
faq: [
{
q: "Should I crop or pad?",
a: "Crop when the new frame is the visible area and edges may go - covers, avatars, video thumbnails. Pad when nothing may be lost - product shots and logos, where a transparent border is safer than a cut-off edge.",
},
{
q: "What does padding add, and what color?",
a: "Transparent. The extra space is left with a zero alpha channel, so the picture stays clean on any background. If you need a solid color instead, run the canvas size or padding tool afterwards.",
},
{
q: "Does cropping lose quality?",
a: "No - crop to fill takes the center at full resolution and only discards the edges. The result is as sharp as the source, just smaller.",
},
{
q: "Can I enter a custom ratio?",
a: "The page offers the common ones. For an unusual frame, resize the image to the size you need first and then crop the excess with the crop tool.",
},
],
},
},
"add-text-png": {
title: "Add text to PNG",
seo: {
metaTitle: "Add Text to PNG - Free Image Text Tool, No Upload",
metaDescription:
"Draw a text label on a PNG in your browser: font, size, color, bold, a position on a 3x3 grid and an optional backing plate. Free, no upload.",
longDescription:
"Adding text to an image draws it straight onto the pixels, which is what you want for a label, a watermark, a caption on a mockup or a date on a photo. Pick the font family, size, weight and color, choose one of nine positions around the frame, set the margin from the edge, and switch on a semi-transparent backing plate when the picture behind the text is too busy. The text is rendered by the browser on your device, so the image never leaves it and the result can be saved as PNG, JPG, WebP or BMP.",
howTo: [
"Drop the image into the source area, or pick a file from your device.",
"Type the text, then set the font, size, bold and color.",
"Choose a position on the 3x3 grid and set the margin from the edge.",
"Turn on the plate and tune its color and opacity if the text needs contrast.",
"Download the result from the Download button.",
],
faq: [
{
q: "Is this a watermark tool?",
a: "It draws a single label at a chosen position. For a repeated pattern across the whole picture, use the watermark tile tool on this site instead.",
},
{
q: "Why is my text hard to read?",
a: "Turn on the backing plate and lower its opacity until the text reads without looking like a sticker, or place the label over a quieter part of the picture by moving the margin.",
},
{
q: "Which fonts are available?",
a: "The browser fonts on your system - a sans-serif and a monospace family are offered. Because the rendering happens locally, the look depends on the fonts installed on the device, which is also why an uploaded PNG always looks the same everywhere.",
},
{
q: "Does adding text reduce image quality?",
a: "The picture is not resampled - the label is drawn on top of the original pixels, and saving as PNG or WebP keeps the rest of the image lossless.",
},
],
},
},
},
tools: { tools: {
"file-size": { "file-size": {
results: { results: {
+5
View File
@@ -51,6 +51,11 @@ export const ru: Dict = {
stepHeading: "Шаг {n}", stepHeading: "Шаг {n}",
removeStepAria: "Убрать шаг", removeStepAria: "Убрать шаг",
stepError: "Шаг {n} ({title}): {msg}", stepError: "Шаг {n} ({title}): {msg}",
howToHeading: "Как пользоваться",
faqHeading: "Вопросы",
relatedHeading: "Похожие инструменты",
breadcrumbAria: "Хлебные крошки",
breadcrumbCatalog: "Все инструменты",
}, },
chain: { chain: {
addStep: "Добавить шаг", addStep: "Добавить шаг",
+71 -1
View File
@@ -3,7 +3,11 @@ import { PAGES } from "../registry";
import { setLocale } from "./locale.svelte"; import { setLocale } from "./locale.svelte";
import { en } from "./en"; import { en } from "./en";
import { ru } from "./ru"; import { ru } from "./ru";
import { searchPages, verdictText } from "./schema-tool-strings"; import {
searchPages,
verdictText,
isPageIndexable,
} from "./schema-tool-strings";
const YO = "\u0451"; const YO = "\u0451";
const YE = "\u0435"; const YE = "\u0435";
@@ -66,6 +70,72 @@ describe("searchPages (cross-locale catalog search)", () => {
}); });
}); });
describe("SEO copy and indexability", () => {
const withCopy = PAGES.filter((page) => en.pages?.[page.slug]?.seo);
function words(text: string): number {
return text.split(/\s+/).filter((word) => word.length > 0).length;
}
it("some pages have copy and some do not (the test is not vacuous)", () => {
expect(withCopy.length).toBeGreaterThan(0);
expect(withCopy.length).toBeLessThan(PAGES.length);
});
it("indexable iff the page has EN copy", () => {
for (const page of PAGES) {
expect(isPageIndexable(page), page.slug).toBe(
en.pages?.[page.slug]?.seo !== undefined,
);
}
});
it("indexable pages keep the tag under a locale switch", () => {
setLocale("ru");
for (const page of withCopy) {
expect(isPageIndexable(page), page.slug).toBe(true);
}
});
// The robots tag follows the presence of the `seo` block, so a stub block
// would index a thin page. These are the gates of docs/plan-seo.md S2.
it.each(withCopy.map((page) => [page.slug]))(
"%s passes the content gate before it can be indexed",
(slug) => {
const seo = en.pages?.[slug]?.seo;
if (!seo) throw new Error(`no copy for ${slug}`);
expect(
seo.longDescription.length,
`${slug} longDescription`,
).toBeGreaterThan(200);
expect(seo.howTo.length, `${slug} howTo`).toBeGreaterThanOrEqual(3);
expect(seo.faq.length, `${slug} faq`).toBeGreaterThanOrEqual(3);
expect(seo.metaTitle.length, `${slug} metaTitle`).toBeGreaterThan(20);
expect(seo.metaTitle.length, `${slug} metaTitle`).toBeLessThan(70);
expect(
seo.metaDescription.length,
`${slug} metaDescription`,
).toBeGreaterThan(70);
expect(
seo.metaDescription.length,
`${slug} metaDescription`,
).toBeLessThan(200);
const total =
words(seo.longDescription) +
seo.howTo.reduce((n, step) => n + words(step), 0) +
seo.faq.reduce((n, item) => n + words(item.q) + words(item.a), 0);
expect(total, `${slug} total words`).toBeGreaterThanOrEqual(250);
},
);
it("copy slugs point at real pages", () => {
const known = new Set(slugs(PAGES));
for (const slug of Object.keys(en.pages ?? {})) {
if (en.pages?.[slug]?.seo) expect(known, slug).toContain(slug);
}
});
});
describe("verdictText (verdict with vars)", () => { describe("verdictText (verdict with vars)", () => {
it("interpolates {vars} in both locales", () => { it("interpolates {vars} in both locales", () => {
setLocale("ru"); setLocale("ru");
+33
View File
@@ -2,9 +2,11 @@ import type { Page } from "$lib/registry";
import { PAGES } from "../registry"; import { PAGES } from "../registry";
import type { ChainWarning } from "$lib/run-chain"; import type { ChainWarning } from "$lib/run-chain";
import type { Field } from "$lib/registry-schema"; import type { Field } from "$lib/registry-schema";
import type { SeoContent } from "./dict";
import { getMergedDict } from "./locale.svelte"; import { getMergedDict } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching"; import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
import { ru } from "./ru"; import { ru } from "./ru";
import { en } from "./en";
import { interpolate, t } from "./t"; import { interpolate, t } from "./t";
function labelOf(id: string): string { function labelOf(id: string): string {
@@ -22,6 +24,37 @@ export function pageDescription(page: Page): string {
return getMergedDict().pages?.[page.slug]?.description ?? page.description; return getMergedDict().pages?.[page.slug]?.description ?? page.description;
} }
/** Long-form page copy, or undefined while a page has none. */
export function pageSeo(page: Page): SeoContent | undefined {
return getMergedDict().pages?.[page.slug]?.seo;
}
/**
* Indexability follows the content: having EN copy is the only signal, so no
* hand-maintained list can drift. Read from the base dict so that switching to
* RU (no `seo` block there yet) cannot flip the robots tag.
*/
export function isPageIndexable(page: Page): boolean {
return en.pages?.[page.slug]?.seo !== undefined;
}
/** SERP `<title>`; the brand prefix is only a fallback for pages without copy. */
export function pageMetaTitle(page: Page): string {
const brand = t("brand");
return (
getMergedDict().pages?.[page.slug]?.seo?.metaTitle ??
`${brand} / ${pageTitle(page)}`
);
}
/** SERP meta description; falls back to the registry description. */
export function pageMetaDescription(page: Page): string {
return (
getMergedDict().pages?.[page.slug]?.seo?.metaDescription ??
pageDescription(page)
);
}
/** Step title for chain UIs: the owning page title, else the raw tool id. */ /** Step title for chain UIs: the owning page title, else the raw tool id. */
export function chainStepTitle(toolId: string): string { export function chainStepTitle(toolId: string): string {
const owner = PAGES.find((p) => p.steps[0].id === toolId); const owner = PAGES.find((p) => p.steps[0].id === toolId);
+3 -1
View File
@@ -53,7 +53,9 @@ describe("smoke: i18n (new UI)", () => {
setLocale("ru"); setLocale("ru");
expect(pageTitle(page)).toBe(ru.pages?.[page.slug]?.title); expect(pageTitle(page)).toBe(ru.pages?.[page.slug]?.title);
setLocale("en"); setLocale("en");
expect(pageTitle(page)).toBe(page.title); // EN uses the registry title unless the dict overrides it (pages whose
// copy was written get an SEO-oriented H1).
expect(pageTitle(page)).toBe(en.pages?.[page.slug]?.title ?? page.title);
}); });
it("5. errors with vars localize to both languages", () => { it("5. errors with vars localize to both languages", () => {
+93
View File
@@ -8,3 +8,96 @@ export const SITE_URL =
typeof rawOrigin === "string" && rawOrigin.trim() !== "" typeof rawOrigin === "string" && rawOrigin.trim() !== ""
? rawOrigin.trim().replace(/\/+$/, "") ? rawOrigin.trim().replace(/\/+$/, "")
: "https://easy-image-tools.com"; : "https://easy-image-tools.com";
export function absoluteUrl(path: string): string {
return `${SITE_URL}${path === "/" ? "" : path}`;
}
type SchemaNode = Record<string, unknown>;
/** Breadcrumb crumb; a crumb without a path is the current page. */
export type Crumb = { name: string; path?: string };
function breadcrumbNode(crumbs: Crumb[]): SchemaNode {
return {
"@type": "BreadcrumbList",
itemListElement: crumbs.map((crumb, index) => ({
"@type": "ListItem",
position: index + 1,
name: crumb.name,
...(crumb.path ? { item: absoluteUrl(crumb.path) } : {}),
})),
};
}
/**
* WebSite + Organization for the home page.
*/
export function siteSchemaLd(brand: string): SchemaNode[] {
return [
{
"@context": "https://schema.org",
"@type": "WebSite",
name: brand,
url: absoluteUrl("/"),
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: brand,
url: absoluteUrl("/"),
logo: absoluteUrl("/favicon.svg"),
},
];
}
/**
* A tool page: what it is, what it costs, and the FAQ it answers. Breadcrumbs
* ride along because the visible trail already exists. Google caps FAQ rich
* results for small sites, so the payoff here is LLM parsing.
*/
export function toolSchemaLd(input: {
title: string;
description: string;
path: string;
faq: { q: string; a: string }[];
crumbs: Crumb[];
}): SchemaNode[] {
const nodes: SchemaNode[] = [
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: input.title,
description: input.description,
url: absoluteUrl(input.path),
applicationCategory: "MultimediaApplication",
operatingSystem: "Any",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
},
];
if (input.faq.length > 0) {
nodes.push({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: input.faq.map((item) => ({
"@type": "Question",
name: item.q,
acceptedAnswer: { "@type": "Answer", text: item.a },
})),
});
}
nodes.push({
"@context": "https://schema.org",
...breadcrumbNode(input.crumbs),
});
return nodes;
}
/** Breadcrumbs for a page that has no tool schema of its own. */
export function breadcrumbSchemaLd(crumbs: Crumb[]): SchemaNode {
return { "@context": "https://schema.org", ...breadcrumbNode(crumbs) };
}
+3
View File
@@ -5,6 +5,7 @@
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/catalog/ToolCard.svelte"; import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import Seo from "$lib/components/Seo.svelte"; import Seo from "$lib/components/Seo.svelte";
import JsonLd from "$lib/components/JsonLd.svelte";
import { favFirst, isFav } from "$lib/favorites.svelte"; import { favFirst, isFav } from "$lib/favorites.svelte";
import { import {
pageDescription, pageDescription,
@@ -13,6 +14,7 @@
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { PAGES } from "$lib/registry"; import { PAGES } from "$lib/registry";
import { siteSchemaLd } from "$lib/seo";
import { toolIcon } from "$lib/tool-icons"; import { toolIcon } from "$lib/tool-icons";
let query = $state(""); let query = $state("");
@@ -39,6 +41,7 @@
</script> </script>
<Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" /> <Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" />
<JsonLd data={siteSchemaLd(t("brand"))} />
<div class="catalog-page"> <div class="catalog-page">
<CatalogHeader total={PREVIEW_TOTAL} /> <CatalogHeader total={PREVIEW_TOTAL} />
+7
View File
@@ -5,6 +5,7 @@
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/catalog/ToolCard.svelte"; import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import Seo from "$lib/components/Seo.svelte"; import Seo from "$lib/components/Seo.svelte";
import JsonLd from "$lib/components/JsonLd.svelte";
import { favFirst, isFav } from "$lib/favorites.svelte"; import { favFirst, isFav } from "$lib/favorites.svelte";
import { import {
pageDescription, pageDescription,
@@ -13,6 +14,7 @@
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { PAGES } from "$lib/registry"; import { PAGES } from "$lib/registry";
import { breadcrumbSchemaLd } from "$lib/seo";
import { toolIcon } from "$lib/tool-icons"; import { toolIcon } from "$lib/tool-icons";
let query = $state(""); let query = $state("");
@@ -43,6 +45,11 @@
description={t("catalog.metaDescription")} description={t("catalog.metaDescription")}
path="/list-tools" path="/list-tools"
/> />
<JsonLd
data={breadcrumbSchemaLd([
{ name: t("toolPage.breadcrumbCatalog"), path: "/list-tools" },
])}
/>
<div class="catalog-page"> <div class="catalog-page">
<CatalogHeader total={PREVIEW_TOTAL} /> <CatalogHeader total={PREVIEW_TOTAL} />
+36 -4
View File
@@ -1,9 +1,19 @@
<script lang="ts"> <script lang="ts">
import Breadcrumbs from "$lib/components/Breadcrumbs.svelte";
import EmptyState from "$lib/components/feedback/EmptyState.svelte"; import EmptyState from "$lib/components/feedback/EmptyState.svelte";
import JsonLd from "$lib/components/JsonLd.svelte";
import PageContent from "$lib/components/PageContent.svelte";
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte"; import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
import Seo from "$lib/components/Seo.svelte"; import Seo from "$lib/components/Seo.svelte";
import { pageDescription, pageTitle } from "$lib/i18n/schema-tool-strings"; import {
isPageIndexable,
pageMetaDescription,
pageMetaTitle,
pageSeo,
pageTitle,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { toolSchemaLd } from "$lib/seo";
import { getPageBySlug, getTool } from "$lib/registry"; import { getPageBySlug, getTool } from "$lib/registry";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte"; import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
@@ -18,16 +28,36 @@
{#if page} {#if page}
<Seo <Seo
title={`${t("brand")} / ${pageTitle(page)}`} title={pageMetaTitle(page)}
description={pageDescription(page)} description={pageMetaDescription(page)}
path={`/tools/${page.slug}`} path={`/tools/${page.slug}`}
/> />
<JsonLd
data={toolSchemaLd({
title: pageTitle(page),
description: pageMetaDescription(page),
path: `/tools/${page.slug}`,
faq: pageSeo(page)?.faq ?? [],
crumbs: [
{ name: t("toolPage.breadcrumbCatalog"), path: "/list-tools" },
{ name: t(`categories.${page.category}`) },
{ name: pageTitle(page) },
],
})}
/>
{/if} {/if}
<!-- Indexable exactly when the page has EN copy: no hand-maintained list, so a
page cannot be indexed with a stub and cannot be forgotten when copy lands. -->
<svelte:head> <svelte:head>
{#if !page} {#if !page}
<title>{t("brand")} / {t("toolPage.fallbackTitle")}</title> <title>{t("brand")} / {t("toolPage.fallbackTitle")}</title>
{/if} {/if}
<meta name="robots" content="noindex, nofollow" /> <meta
name="robots"
content={page && isPageIndexable(page)
? "index, follow"
: "noindex, nofollow"}
/>
</svelte:head> </svelte:head>
{#if !page || !tool} {#if !page || !tool}
@@ -39,7 +69,9 @@
/> />
</div> </div>
{:else} {:else}
<Breadcrumbs {page} />
<SchemaToolView {page} {tool} /> <SchemaToolView {page} {tool} />
<PageContent {page} />
{/if} {/if}
<style> <style>
+22 -1
View File
@@ -2,5 +2,26 @@ window.dataLayer = window.dataLayer || [];
function gtag() { function gtag() {
window.dataLayer.push(arguments); 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("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,
});