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
+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>