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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user