mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add json-ld
This commit is contained in:
@@ -34,6 +34,16 @@
|
||||
правило такое (запретить toContain без i18n)
|
||||
- [ ] посмотреть ошибки e2e, разрешить запускать е2е без ручного подтверждения,
|
||||
и рассмотреть - может уже пора включить их в ci\cd гейт
|
||||
- [ ] пересмотреть `{@html}` в `web/src/lib/components/JsonLd.svelte`: сейчас
|
||||
там инлайновый `eslint-disable-next-line svelte/no-at-html-tags`, потому
|
||||
что `<script>{json}</script>` не подставляет значение (Svelte читает
|
||||
содержимое script как raw text — это видно по ошибке «'json' is assigned a
|
||||
value but never used»), а другой способ вставки разметки в Svelte нет.
|
||||
Варианты к рассмотрению: исключение для файла в `web/eslint.config.js`
|
||||
вместо suppress'а в коде; генерация JSON-LD на этапе сборки рядом с
|
||||
sitemap (`web/scripts/gen-sitemap.mjs`); отказ от компонента. Критерий
|
||||
закрытия: в production-коде нет `eslint-disable`, и разметка продолжает
|
||||
попадать в собранный HTML
|
||||
|
||||
## Pipeline
|
||||
|
||||
|
||||
+12
-5
@@ -326,11 +326,18 @@
|
||||
|
||||
### S3. Структурированные данные + GEO
|
||||
|
||||
- [ ] **[S3a]** JSON-LD: `SoftwareApplication` (+ `offers` price 0) и `FAQPage`
|
||||
на страницах инструментов; `WebSite` + `Organization` на главной;
|
||||
`BreadcrumbList` на всех внутренних. Учесть: Google ограничил rich results
|
||||
по FAQ/HowTo для мелких сайтов — разметка нужна прежде всего для
|
||||
LLM-парсинга, она безвредна.
|
||||
- [x] **[S3a]** JSON-LD — на странице инструмента `SoftwareApplication`
|
||||
(`offers` price 0), `FAQPage` (из уже написанного FAQ) и `BreadcrumbList`
|
||||
(из тех же крошек, что видны в UI); на главной — `WebSite` +
|
||||
`Organization`; на каталоге — `BreadcrumbList`. Построители в
|
||||
`web/src/lib/seo.ts`, вывод — `web/src/lib/components/JsonLd.svelte`.
|
||||
Проверено на сборке: три разбора в `build/tools/crop-png.html`, FAQPage с
|
||||
4 вопросами, два узла на главной. **Известная грязь:** в `JsonLd.svelte`
|
||||
инлайновый `eslint-disable svelte/no-at-html-tags` — `{@html}`
|
||||
единственный способ вставить значение в Svelte, а
|
||||
`<script>{json}</script>` не работает (Svelte читает содержимое script как
|
||||
raw text). Suppress обоснован экранированием `<` в `\u003c`, но
|
||||
пересмотрен не позже: задача в `docs/backlog.md`, раздел «Тесты и lint».
|
||||
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
|
||||
инструментов с одним предложением на каждый (генерится из реестра тем же
|
||||
скриптом, что sitemap).
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
data: Record<string, unknown> | Record<string, unknown>[];
|
||||
}
|
||||
let { data }: Props = $props();
|
||||
|
||||
// `<` is escaped so a value containing markup can never close the script tag
|
||||
// early; U+2028/U+2029 are escaped because they break JS string literals.
|
||||
const json = $derived(
|
||||
JSON.stringify(data)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/[\u2028\u2029]/g, (c) =>
|
||||
c === "\u2028" ? "\\u2028" : "\\u2029",
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- The closing tag is built from two parts: a literal `</script>` inside the
|
||||
template would end the block the parser is reading. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- json is escaped above, the only markup is this script wrapper -->
|
||||
{@html `<script type="application/ld+json">${json}</` + "script>"}
|
||||
@@ -8,3 +8,96 @@ export const SITE_URL =
|
||||
typeof rawOrigin === "string" && rawOrigin.trim() !== ""
|
||||
? rawOrigin.trim().replace(/\/+$/, "")
|
||||
: "https://easy-image-tools.com";
|
||||
|
||||
export function absoluteUrl(path: string): string {
|
||||
return `${SITE_URL}${path === "/" ? "" : path}`;
|
||||
}
|
||||
|
||||
type SchemaNode = Record<string, unknown>;
|
||||
|
||||
/** Breadcrumb crumb; a crumb without a path is the current page. */
|
||||
export type Crumb = { name: string; path?: string };
|
||||
|
||||
function breadcrumbNode(crumbs: Crumb[]): SchemaNode {
|
||||
return {
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: crumbs.map((crumb, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
...(crumb.path ? { item: absoluteUrl(crumb.path) } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSite + Organization for the home page.
|
||||
*/
|
||||
export function siteSchemaLd(brand: string): SchemaNode[] {
|
||||
return [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: brand,
|
||||
url: absoluteUrl("/"),
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: brand,
|
||||
url: absoluteUrl("/"),
|
||||
logo: absoluteUrl("/favicon.svg"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool page: what it is, what it costs, and the FAQ it answers. Breadcrumbs
|
||||
* ride along because the visible trail already exists. Google caps FAQ rich
|
||||
* results for small sites, so the payoff here is LLM parsing.
|
||||
*/
|
||||
export function toolSchemaLd(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
faq: { q: string; a: string }[];
|
||||
crumbs: Crumb[];
|
||||
}): SchemaNode[] {
|
||||
const nodes: SchemaNode[] = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: input.title,
|
||||
description: input.description,
|
||||
url: absoluteUrl(input.path),
|
||||
applicationCategory: "MultimediaApplication",
|
||||
operatingSystem: "Any",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
},
|
||||
];
|
||||
if (input.faq.length > 0) {
|
||||
nodes.push({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: input.faq.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.a },
|
||||
})),
|
||||
});
|
||||
}
|
||||
nodes.push({
|
||||
"@context": "https://schema.org",
|
||||
...breadcrumbNode(input.crumbs),
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Breadcrumbs for a page that has no tool schema of its own. */
|
||||
export function breadcrumbSchemaLd(crumbs: Crumb[]): SchemaNode {
|
||||
return { "@context": "https://schema.org", ...breadcrumbNode(crumbs) };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||
import Seo from "$lib/components/Seo.svelte";
|
||||
import JsonLd from "$lib/components/JsonLd.svelte";
|
||||
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||
import {
|
||||
pageDescription,
|
||||
@@ -13,6 +14,7 @@
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { PAGES } from "$lib/registry";
|
||||
import { siteSchemaLd } from "$lib/seo";
|
||||
import { toolIcon } from "$lib/tool-icons";
|
||||
|
||||
let query = $state("");
|
||||
@@ -39,6 +41,7 @@
|
||||
</script>
|
||||
|
||||
<Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" />
|
||||
<JsonLd data={siteSchemaLd(t("brand"))} />
|
||||
|
||||
<div class="catalog-page">
|
||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||
import Seo from "$lib/components/Seo.svelte";
|
||||
import JsonLd from "$lib/components/JsonLd.svelte";
|
||||
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||
import {
|
||||
pageDescription,
|
||||
@@ -13,6 +14,7 @@
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { PAGES } from "$lib/registry";
|
||||
import { breadcrumbSchemaLd } from "$lib/seo";
|
||||
import { toolIcon } from "$lib/tool-icons";
|
||||
|
||||
let query = $state("");
|
||||
@@ -43,6 +45,11 @@
|
||||
description={t("catalog.metaDescription")}
|
||||
path="/list-tools"
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbSchemaLd([
|
||||
{ name: t("toolPage.breadcrumbCatalog"), path: "/list-tools" },
|
||||
])}
|
||||
/>
|
||||
|
||||
<div class="catalog-page">
|
||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Breadcrumbs from "$lib/components/Breadcrumbs.svelte";
|
||||
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
|
||||
import JsonLd from "$lib/components/JsonLd.svelte";
|
||||
import PageContent from "$lib/components/PageContent.svelte";
|
||||
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
|
||||
import Seo from "$lib/components/Seo.svelte";
|
||||
@@ -8,8 +9,11 @@
|
||||
isPageIndexable,
|
||||
pageMetaDescription,
|
||||
pageMetaTitle,
|
||||
pageSeo,
|
||||
pageTitle,
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { toolSchemaLd } from "$lib/seo";
|
||||
import { getPageBySlug, getTool } from "$lib/registry";
|
||||
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
||||
|
||||
@@ -28,6 +32,19 @@
|
||||
description={pageMetaDescription(page)}
|
||||
path={`/tools/${page.slug}`}
|
||||
/>
|
||||
<JsonLd
|
||||
data={toolSchemaLd({
|
||||
title: pageTitle(page),
|
||||
description: pageMetaDescription(page),
|
||||
path: `/tools/${page.slug}`,
|
||||
faq: pageSeo(page)?.faq ?? [],
|
||||
crumbs: [
|
||||
{ name: t("toolPage.breadcrumbCatalog"), path: "/list-tools" },
|
||||
{ name: t(`categories.${page.category}`) },
|
||||
{ name: pageTitle(page) },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Indexable exactly when the page has EN copy: no hand-maintained list, so a
|
||||
page cannot be indexed with a stub and cannot be forgotten when copy lands. -->
|
||||
|
||||
Reference in New Issue
Block a user