diff --git a/docs/backlog.md b/docs/backlog.md index e8063e3..601b9fe 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -10,7 +10,7 @@ ## Тесты и lint -- [ ] правило - только английские комментарии не работает на svelte файлы +- [x] правило - только английские комментарии не работает на svelte файлы - [ ] правило - ограницить комментарии вроде уменьшило простыни из комментариев, но всё равно они есть и часто пишет очевидные вещи. надо более строгое правило. может такой вариант: разрешено писать комментарии в коде, но @@ -230,13 +230,16 @@ ## SEO/GEO -- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и +- [ ] исследовать - стоит сделать страницу категорий? для crumbs +- [ ] related tools - после добавления популярности инструментов учитывать самые + популярные инструменты +- [x] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4 - [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по вордстату с уникальным контентом; редиректы только для опечаток и переименований уже опубликованных страниц; клонов с canonical не делать (`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) — у каждой страницы инструмента своя превью-карточка 1200×630 для соцсетей/мессенджеров: имя инструмента, бренд, иконка. Сейчас все страницы ссылаются на общую @@ -246,10 +249,10 @@ инструмента в `static/og/[slug].png`, в `Seo.svelte` подставлять og-URL по slug страницы. Критерий приёмки: у `/tools/flip-png` в og:image своя картинка с названием инструмента, файл лежит в `static/og/` -- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2) +- [x] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2) - [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3) - [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c) -- [ ] GSC: подключить домен, отправить sitemap, мониторинг индексации (S4a) +- [x] GSC: подключить домен, отправить sitemap, мониторинг индексации (S4a) - [ ] GA4: cookieless-конфигурация, отключить рекламные audiences и signals, `anonymize_ip` — сейчас в `web/src/routes/+layout.svelte` голый `gtag('config')` (S4b) diff --git a/docs/plan-seo.md b/docs/plan-seo.md index 5ab4d50..54351b3 100644 --- a/docs/plan-seo.md +++ b/docs/plan-seo.md @@ -292,9 +292,16 @@ без текста не меняются. Новый токен `--size-content-prose` (мера колонки текста) появился вместе с потребителем. **Не сделано и вынесено в S2c:** related tools и хлебные крошки. -- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI. Related - tools по категории (2–3 «соседних» интента) — здесь же: это внутренняя - перелинковка, без неё страницы ядра плохо связаны между собой. +- [x] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) и related + tools — **сделано 2026-10-12**: `Breadcrumbs.svelte` (над H1, первая + крошка ссылается на `/list-tools`, средняя — текст до появления хабов + категорий, S3c) и блок Related из 6 карточек под FAQ. **Related выводится, + а не перечисляется:** `relatedPages()` в `web/src/lib/catalog.ts` берёт + соседей по категории в порядке каталога и добирает из остального каталога, + если категория мала (в `text` всего три страницы — это поймал тест). + Список руками не ведётся, новый инструмент попадает в чужие related сам. + Тесты: `web/src/lib/catalog.test.ts` (категория, детерминированность, + достаточность соседей). - [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра (приоритет по вордстату: transparent png, jpg→png, compress, resize, crop, rotate, circle crop, add text, pixelate, grayscale…); отбор — §5. **Ядро diff --git a/web/src/lib/catalog.test.ts b/web/src/lib/catalog.test.ts new file mode 100644 index 0000000..7791393 --- /dev/null +++ b/web/src/lib/catalog.test.ts @@ -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([]); + }); +}); diff --git a/web/src/lib/catalog.ts b/web/src/lib/catalog.ts index 63038a5..fe49212 100644 --- a/web/src/lib/catalog.ts +++ b/web/src/lib/catalog.ts @@ -15,3 +15,21 @@ export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({ })).filter((group) => group.pages.length > 0); 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); +} diff --git a/web/src/lib/components/Breadcrumbs.svelte b/web/src/lib/components/Breadcrumbs.svelte new file mode 100644 index 0000000..768edec --- /dev/null +++ b/web/src/lib/components/Breadcrumbs.svelte @@ -0,0 +1,48 @@ + + + + + + diff --git a/web/src/lib/components/PageContent.svelte b/web/src/lib/components/PageContent.svelte index 6470763..6693566 100644 --- a/web/src/lib/components/PageContent.svelte +++ b/web/src/lib/components/PageContent.svelte @@ -1,7 +1,14 @@ +

{t("toolPage.relatedHeading")}

+ {/if} @@ -91,9 +114,17 @@ 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; + } } diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index 1adf2c9..b62def6 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -1,12 +1,12 @@