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)
|
правило такое (запретить toContain без i18n)
|
||||||
- [ ] посмотреть ошибки e2e, разрешить запускать е2е без ручного подтверждения,
|
- [ ] посмотреть ошибки e2e, разрешить запускать е2е без ручного подтверждения,
|
||||||
и рассмотреть - может уже пора включить их в ci\cd гейт
|
и рассмотреть - может уже пора включить их в 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
|
## Pipeline
|
||||||
|
|
||||||
|
|||||||
+12
-5
@@ -326,11 +326,18 @@
|
|||||||
|
|
||||||
### S3. Структурированные данные + GEO
|
### S3. Структурированные данные + GEO
|
||||||
|
|
||||||
- [ ] **[S3a]** JSON-LD: `SoftwareApplication` (+ `offers` price 0) и `FAQPage`
|
- [x] **[S3a]** JSON-LD — на странице инструмента `SoftwareApplication`
|
||||||
на страницах инструментов; `WebSite` + `Organization` на главной;
|
(`offers` price 0), `FAQPage` (из уже написанного FAQ) и `BreadcrumbList`
|
||||||
`BreadcrumbList` на всех внутренних. Учесть: Google ограничил rich results
|
(из тех же крошек, что видны в UI); на главной — `WebSite` +
|
||||||
по FAQ/HowTo для мелких сайтов — разметка нужна прежде всего для
|
`Organization`; на каталоге — `BreadcrumbList`. Построители в
|
||||||
LLM-парсинга, она безвредна.
|
`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` в статике: короткий манифест сайта + список
|
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
|
||||||
инструментов с одним предложением на каждый (генерится из реестра тем же
|
инструментов с одним предложением на каждый (генерится из реестра тем же
|
||||||
скриптом, что sitemap).
|
скриптом, что 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() !== ""
|
typeof rawOrigin === "string" && rawOrigin.trim() !== ""
|
||||||
? rawOrigin.trim().replace(/\/+$/, "")
|
? rawOrigin.trim().replace(/\/+$/, "")
|
||||||
: "https://easy-image-tools.com";
|
: "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 CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||||
import Seo from "$lib/components/Seo.svelte";
|
import Seo from "$lib/components/Seo.svelte";
|
||||||
|
import JsonLd from "$lib/components/JsonLd.svelte";
|
||||||
import { favFirst, isFav } from "$lib/favorites.svelte";
|
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||||
import {
|
import {
|
||||||
pageDescription,
|
pageDescription,
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { PAGES } from "$lib/registry";
|
import { PAGES } from "$lib/registry";
|
||||||
|
import { siteSchemaLd } from "$lib/seo";
|
||||||
import { toolIcon } from "$lib/tool-icons";
|
import { toolIcon } from "$lib/tool-icons";
|
||||||
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" />
|
<Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" />
|
||||||
|
<JsonLd data={siteSchemaLd(t("brand"))} />
|
||||||
|
|
||||||
<div class="catalog-page">
|
<div class="catalog-page">
|
||||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
|
||||||
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
import ToolCard from "$lib/components/catalog/ToolCard.svelte";
|
||||||
import Seo from "$lib/components/Seo.svelte";
|
import Seo from "$lib/components/Seo.svelte";
|
||||||
|
import JsonLd from "$lib/components/JsonLd.svelte";
|
||||||
import { favFirst, isFav } from "$lib/favorites.svelte";
|
import { favFirst, isFav } from "$lib/favorites.svelte";
|
||||||
import {
|
import {
|
||||||
pageDescription,
|
pageDescription,
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { PAGES } from "$lib/registry";
|
import { PAGES } from "$lib/registry";
|
||||||
|
import { breadcrumbSchemaLd } from "$lib/seo";
|
||||||
import { toolIcon } from "$lib/tool-icons";
|
import { toolIcon } from "$lib/tool-icons";
|
||||||
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
@@ -43,6 +45,11 @@
|
|||||||
description={t("catalog.metaDescription")}
|
description={t("catalog.metaDescription")}
|
||||||
path="/list-tools"
|
path="/list-tools"
|
||||||
/>
|
/>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbSchemaLd([
|
||||||
|
{ name: t("toolPage.breadcrumbCatalog"), path: "/list-tools" },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="catalog-page">
|
<div class="catalog-page">
|
||||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Breadcrumbs from "$lib/components/Breadcrumbs.svelte";
|
import Breadcrumbs from "$lib/components/Breadcrumbs.svelte";
|
||||||
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
|
import EmptyState from "$lib/components/feedback/EmptyState.svelte";
|
||||||
|
import JsonLd from "$lib/components/JsonLd.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";
|
||||||
import Seo from "$lib/components/Seo.svelte";
|
import Seo from "$lib/components/Seo.svelte";
|
||||||
@@ -8,8 +9,11 @@
|
|||||||
isPageIndexable,
|
isPageIndexable,
|
||||||
pageMetaDescription,
|
pageMetaDescription,
|
||||||
pageMetaTitle,
|
pageMetaTitle,
|
||||||
|
pageSeo,
|
||||||
|
pageTitle,
|
||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { toolSchemaLd } from "$lib/seo";
|
||||||
import { getPageBySlug, getTool } from "$lib/registry";
|
import { getPageBySlug, getTool } from "$lib/registry";
|
||||||
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
||||||
|
|
||||||
@@ -28,6 +32,19 @@
|
|||||||
description={pageMetaDescription(page)}
|
description={pageMetaDescription(page)}
|
||||||
path={`/tools/${page.slug}`}
|
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}
|
{/if}
|
||||||
<!-- Indexable exactly when the page has EN copy: no hand-maintained list, so a
|
<!-- 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. -->
|
page cannot be indexed with a stub and cannot be forgotten when copy lands. -->
|
||||||
|
|||||||
Reference in New Issue
Block a user