feat: initial seo descriptions texts

This commit is contained in:
2026-10-12 15:13:25 +05:00
parent 7a61f5a85b
commit 984f71c822
11 changed files with 280 additions and 18 deletions
+38 -10
View File
@@ -81,6 +81,19 @@
открытия страницы. **Рекламу и ремаркетинг не ставим** — они противоречат
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
- **Три разных артефакта на странице, три разных источника.** `<title>` для
выдачи (`seo.metaTitle`, ~55–60 символов **без префикса бренда** — «Crop PNG
Online - Free Image Cropper, No Upload»: бренд не приносит собственных
поисковых запросов и съедает треть выдачи), `meta description`
(`seo.metaDescription`, ~150–160 символов) и H1 (`pages.<slug>.title`, чистый
интент: «Crop PNG online»). **Решение по «free / no upload / нет лимитов»:**
прямо в H1 они не идут — Google не вознаграждает ключи в заголовке,
переписывает title примерно в 60% случаев, а список выгод в H1 читается как
сборка ключей. Эти обещания работают в `meta description` (влияет на
кликабельность) и в первом абзаце. **Форматы в H1 не перечисляем:** широкие
интенты («crop jpg») закрывает текст первого абзаца, description и FAQ, а не
заголовок; это же запрещено §5 как doorway-подход. Реестр не трогаем: `title`
в нём остаётся для каталога и цепочек, SEO-заголовки живут в словаре.
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
каждому инструменту, с ручной вычиткой.
@@ -245,18 +258,33 @@
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
вручную пачками по категориям (самый трудоёмкий этап: объём растёт вместе
с числом инструментов × 2 локали; RU — отложить до решения о `/ru/`).
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
внутри категории + 2–3 «соседних» интента).
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI.
- [x] **[S2a]** Схема данных: `seo`-блок per-tool — **сделано 2026-10-12**: тип
`SeoContent` (`longDescription`, `howTo: string[]`, `faq: {q, a}[]`) в
`web/src/lib/i18n/dict.ts`, поле `seo?` в `PageStrings`, доступ
`pageSeo(page)` в `web/src/lib/i18n/schema-tool-strings.ts`. Хранится в
словаре `pages.<slug>.seo`, не в реестре: у реестра нет EN-копии длинного
текста по определению, а словарь — единственное место, где лежит текст.
**Пока EN-only:** `ignoreMissingPatterns: ["pages.*.seo"]` в
`web/eslint.config.js` снимает требование паритета локалей для этого
блока, RU появится вместе с `/ru/` (§0). Первые тексты — `crop-png`,
`resize-png`.
- [x] **[S2b]** Компонент контента страницы инструмента — **сделано 2026-10-12**
минимальной версией: `web/src/lib/components/PageContent.svelte` рендерит
`longDescription` абзацем, `howTo` списком `<ol>`, `faq` через `<details>`
(текст в DOM, ответ не требует скролла); подключён в
`web/src/routes/tools/[slug]/+page.svelte` под рабочей областью
инструмента и рендерится только при наличии `seo`-блока, так что страницы
без текста не меняются. Новый токен `--size-content-prose` (мера колонки
текста) появился вместе с потребителем. **Не сделано и вынесено в S2c:**
related tools и хлебные крошки.
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI. Related
tools по категории (2–3 «соседних» интента) — здесь же: это внутренняя
перелинковка, без неё страницы ядра плохо связаны между собой.
- [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
(приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5.
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5. **В
работе:** 2 страницы из 5-стадия (crop-png, resize-png) — это пилот формы,
а не волна; до снятия `noindex` (S1h) нужны минимум все страницы §5.
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
рантайме.
+4 -1
View File
@@ -102,7 +102,10 @@
проверяются.
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
проверяются на 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`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
+1 -1
View File
@@ -164,7 +164,7 @@ export default tseslint.config(
"warn",
{
baseLocaleFallback: ["pages.*.title", "pages.*.description"],
ignoreMissingPatterns: ["tools.*.params"],
ignoreMissingPatterns: ["tools.*.params", "pages.*.seo"],
},
],
},
+2
View File
@@ -162,6 +162,8 @@
--size-parts-grid-min: 120px;
--size-workspace-min: 260px;
--size-content-max-wide: 1680px;
--size-content-prose: 1120px;
--size-content-measure: 84ch;
--font-size-s: 12px;
--font-size-m: 14px;
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { pageSeo } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import type { Page } from "$lib/registry";
interface Props {
page: Page;
}
let { page }: Props = $props();
const seo = $derived(pageSeo(page));
</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>
</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;
}
@media (--bp-tablet) {
.long {
font-size: var(--font-size-m);
}
}
</style>
+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 = {
title?: string;
description?: string;
/** Absent on pages whose copy is not written yet. */
seo?: SeoContent;
};
/** Tool strings (`tools.<id>`): field labels, verdict keys. */
+76 -1
View File
@@ -50,6 +50,8 @@ export const en: Dict = {
stepHeading: "Step {n}",
removeStepAria: "Remove step",
stepError: "Step {n} ({title}): {msg}",
howToHeading: "How to use it",
faqHeading: "Questions",
},
chain: {
addStep: "Add step",
@@ -392,7 +394,80 @@ export const en: Dict = {
},
// Page titles/descriptions are not duplicated in the base locale: the EN
// 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.",
},
],
},
},
},
tools: {
"file-size": {
results: {
+2
View File
@@ -51,6 +51,8 @@ export const ru: Dict = {
stepHeading: "Шаг {n}",
removeStepAria: "Убрать шаг",
stepError: "Шаг {n} ({title}): {msg}",
howToHeading: "Как пользоваться",
faqHeading: "Вопросы",
},
chain: {
addStep: "Добавить шаг",
+23
View File
@@ -2,6 +2,7 @@ import type { Page } from "$lib/registry";
import { PAGES } from "../registry";
import type { ChainWarning } from "$lib/run-chain";
import type { Field } from "$lib/registry-schema";
import type { SeoContent } from "./dict";
import { getMergedDict } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
import { ru } from "./ru";
@@ -22,6 +23,28 @@ export function pageDescription(page: Page): string {
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;
}
/** 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. */
export function chainStepTitle(toolId: string): string {
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");
expect(pageTitle(page)).toBe(ru.pages?.[page.slug]?.title);
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", () => {
+8 -3
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
import PageContent from "$lib/components/PageContent.svelte";
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
import Seo from "$lib/components/Seo.svelte";
import { pageDescription, pageTitle } from "$lib/i18n/schema-tool-strings";
import {
pageMetaDescription,
pageMetaTitle,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { getPageBySlug, getTool } from "$lib/registry";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
@@ -18,8 +22,8 @@
{#if page}
<Seo
title={`${t("brand")} / ${pageTitle(page)}`}
description={pageDescription(page)}
title={pageMetaTitle(page)}
description={pageMetaDescription(page)}
path={`/tools/${page.slug}`}
/>
{/if}
@@ -40,6 +44,7 @@
</div>
{:else}
<SchemaToolView {page} {tool} />
<PageContent {page} />
{/if}
<style>