feat: search metadata, structured data and indexing rules

Only the production deployment may be indexed; testing and preview builds
answer with noindex and a closed robots file. The homepage now describes the
person, the services and the FAQ as structured data, and title, description
and the 404 page are written for what people search for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:07:03 +02:00
co-authored by Claude Opus 5.5
parent 390936856c
commit e9f05b02fc
18 changed files with 563 additions and 89 deletions
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import {
buildStructuredData,
serializeJsonLd,
type StructuredDataInput,
} from "./structured-data";
const input: StructuredDataInput = {
siteUrl: "https://example.com",
siteName: "Jane Doe",
language: "en",
description: "Freelance web developer.",
imageUrl: "https://example.com/jane.png",
person: {
name: "Jane Doe",
jobTitle: "Freelance web developer",
profiles: ["https://github.com/jane", "https://linkedin.com/in/jane"],
knowsAbout: ["React", "Websites"],
},
address: {
street: "Main Street 1",
postalCode: "12345",
locality: "Town",
countryCode: "DE",
},
services: [
{ name: "Web app development", description: "I join your project." },
{ name: "Website development", description: "A website, built for you." },
],
faq: [
{ question: "Who owns the code?", answer: "You do." },
{ question: "How do I reach you?", answer: "By email." },
],
};
const node = (data: ReturnType<typeof buildStructuredData>, type: string) =>
data["@graph"].find((entry) => entry["@type"] === type);
describe("buildStructuredData", () => {
it("is a schema.org graph of the website, the person, the business and the questions", () => {
const data = buildStructuredData(input);
expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"].map((entry) => entry["@type"])).toEqual([
"WebSite",
"Person",
"ProfessionalService",
"FAQPage",
]);
});
it("describes the website and names the person as its publisher", () => {
expect(node(buildStructuredData(input), "WebSite")).toEqual({
"@type": "WebSite",
"@id": "https://example.com/#website",
url: "https://example.com",
name: "Jane Doe",
description: "Freelance web developer.",
inLanguage: "en",
publisher: { "@id": "https://example.com/#person" },
});
});
it("describes the person with job title, portrait, topics and profiles", () => {
expect(node(buildStructuredData(input), "Person")).toEqual({
"@type": "Person",
"@id": "https://example.com/#person",
name: "Jane Doe",
url: "https://example.com",
image: "https://example.com/jane.png",
jobTitle: "Freelance web developer",
knowsAbout: ["React", "Websites"],
sameAs: ["https://github.com/jane", "https://linkedin.com/in/jane"],
});
});
it("leaves out the profile list when there are no profiles", () => {
const data = buildStructuredData({
...input,
person: { ...input.person, profiles: [] },
});
expect(node(data, "Person")).not.toHaveProperty("sameAs");
});
it("describes the business with its address, founder and one offer per service", () => {
expect(node(buildStructuredData(input), "ProfessionalService")).toEqual({
"@type": "ProfessionalService",
"@id": "https://example.com/#business",
name: "Jane Doe",
url: "https://example.com",
description: "Freelance web developer.",
image: "https://example.com/jane.png",
founder: { "@id": "https://example.com/#person" },
address: {
"@type": "PostalAddress",
streetAddress: "Main Street 1",
postalCode: "12345",
addressLocality: "Town",
addressCountry: "DE",
},
hasOfferCatalog: {
"@type": "OfferCatalog",
name: "Services",
itemListElement: [
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Web app development",
description: "I join your project.",
provider: { "@id": "https://example.com/#business" },
},
},
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Website development",
description: "A website, built for you.",
provider: { "@id": "https://example.com/#business" },
},
},
],
},
});
});
it("lists every question with its answer", () => {
expect(node(buildStructuredData(input), "FAQPage")).toEqual({
"@type": "FAQPage",
"@id": "https://example.com/#faq",
mainEntity: [
{
"@type": "Question",
name: "Who owns the code?",
acceptedAnswer: { "@type": "Answer", text: "You do." },
},
{
"@type": "Question",
name: "How do I reach you?",
acceptedAnswer: { "@type": "Answer", text: "By email." },
},
],
});
});
it("leaves out the questions node when there are no questions", () => {
const data = buildStructuredData({ ...input, faq: [] });
expect(data["@graph"].map((entry) => entry["@type"])).toEqual([
"WebSite",
"Person",
"ProfessionalService",
]);
});
});
describe("serializeJsonLd", () => {
it("writes JSON that parses back to the same data", () => {
const data = buildStructuredData(input);
expect(JSON.parse(serializeJsonLd(data))).toEqual(data);
});
it("escapes every opening angle bracket so text cannot close the script tag", () => {
const data = buildStructuredData({
...input,
description: "</script><script>alert(1)</script>",
});
const json = serializeJsonLd(data);
expect(json).not.toContain("<");
expect(json).toContain("\\u003c/script>\\u003cscript>");
expect(JSON.parse(json)).toEqual(data);
});
});