feat: add json-ld

This commit is contained in:
2026-10-12 17:07:59 +05:00
parent 4bb4efd29b
commit 1981afbc64
7 changed files with 163 additions and 5 deletions
+21
View File
@@ -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>"}
+93
View File
@@ -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) };
}
+3
View File
@@ -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} />
+7
View File
@@ -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} />
+17
View File
@@ -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. -->