feat: breadcrumbs, related tools

This commit is contained in:
2026-10-12 16:37:35 +05:00
parent 5184878589
commit 399c3fb282
10 changed files with 182 additions and 17 deletions
+8 -5
View File
@@ -10,7 +10,7 @@
## Тесты и lint ## Тесты и lint
- [ ] правило - только английские комментарии не работает на svelte файлы - [x] правило - только английские комментарии не работает на svelte файлы
- [ ] правило - ограницить комментарии вроде уменьшило простыни из комментариев, - [ ] правило - ограницить комментарии вроде уменьшило простыни из комментариев,
но всё равно они есть и часто пишет очевидные вещи. надо более строгое но всё равно они есть и часто пишет очевидные вещи. надо более строгое
правило. может такой вариант: разрешено писать комментарии в коде, но правило. может такой вариант: разрешено писать комментарии в коде, но
@@ -230,13 +230,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 +249,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)
+10 -3
View File
@@ -292,9 +292,16 @@
без текста не меняются. Новый токен `--size-content-prose` (мера колонки без текста не меняются. Новый токен `--size-content-prose` (мера колонки
текста) появился вместе с потребителем. **Не сделано и вынесено в S2c:** текста) появился вместе с потребителем. **Не сделано и вынесено в S2c:**
related tools и хлебные крошки. related tools и хлебные крошки.
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI. Related - [x] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) и related
tools по категории (2–3 «соседних» интента) — здесь же: это внутренняя 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]** Первая волна контента — топ приоритетных инструментов ядра - [ ] **[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. **Ядро
+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>
+32 -1
View File
@@ -1,7 +1,14 @@
<script lang="ts"> <script lang="ts">
import { pageSeo } from "$lib/i18n/schema-tool-strings"; 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 { t } from "$lib/i18n/t";
import type { Page } from "$lib/registry"; import type { Page } from "$lib/registry";
import { toolIcon } from "$lib/tool-icons";
interface Props { interface Props {
page: Page; page: Page;
@@ -9,6 +16,7 @@
let { page }: Props = $props(); let { page }: Props = $props();
const seo = $derived(pageSeo(page)); const seo = $derived(pageSeo(page));
const related = $derived(relatedPages(page, 6));
</script> </script>
<!-- Below the tool, not above it: the workspace is what the visitor came for, <!-- Below the tool, not above it: the workspace is what the visitor came for,
@@ -33,6 +41,21 @@
</details> </details>
{/each} {/each}
</div> </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> </section>
{/if} {/if}
@@ -91,9 +114,17 @@
font-size: var(--font-size-m); font-size: var(--font-size-m);
line-height: 1.6; line-height: 1.6;
} }
.related {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-l);
}
@media (--bp-tablet) { @media (--bp-tablet) {
.long { .long {
font-size: var(--font-size-m); font-size: var(--font-size-m);
} }
.related {
grid-template-columns: 1fr;
}
} }
</style> </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 {
+3
View File
@@ -52,6 +52,9 @@ export const en: Dict = {
stepError: "Step {n} ({title}): {msg}", stepError: "Step {n} ({title}): {msg}",
howToHeading: "How to use it", howToHeading: "How to use it",
faqHeading: "Questions", faqHeading: "Questions",
relatedHeading: "Related tools",
breadcrumbAria: "Breadcrumb",
breadcrumbCatalog: "All tools",
}, },
chain: { chain: {
addStep: "Add step", addStep: "Add step",
+3
View File
@@ -53,6 +53,9 @@ export const ru: Dict = {
stepError: "Шаг {n} ({title}): {msg}", stepError: "Шаг {n} ({title}): {msg}",
howToHeading: "Как пользоваться", howToHeading: "Как пользоваться",
faqHeading: "Вопросы", faqHeading: "Вопросы",
relatedHeading: "Похожие инструменты",
breadcrumbAria: "Хлебные крошки",
breadcrumbCatalog: "Все инструменты",
}, },
chain: { chain: {
addStep: "Добавить шаг", addStep: "Добавить шаг",
+2
View File
@@ -1,4 +1,5 @@
<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 PageContent from "$lib/components/PageContent.svelte"; import PageContent from "$lib/components/PageContent.svelte";
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte"; import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
@@ -51,6 +52,7 @@
/> />
</div> </div>
{:else} {:else}
<Breadcrumbs {page} />
<SchemaToolView {page} {tool} /> <SchemaToolView {page} {tool} />
<PageContent {page} /> <PageContent {page} />
{/if} {/if}