mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: breadcrumbs, related tools
This commit is contained in:
+8
-5
@@ -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
@@ -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. **Ядро
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Добавить шаг",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user