The site now exists in German (at the root, with /impressum and /datenschutz) and English (/en). A request without a language follows the visitor's choice from the language switch, then the browser language, then German; the old English legal addresses move to /en for good. All visible texts are data in src/content/de.ts and en.ts, so components hold none of their own. The header carries a DE/EN switch whose bit curtain covers the page while it is swapped and reopens at the same reading position. Title, description, canonical, hreflang, Open Graph, the share image, the sitemap and the structured data are per language. The privacy notice now describes the cookie that remembers the chosen language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
6.6 KiB
TypeScript
213 lines
6.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildStructuredData,
|
|
serializeJsonLd,
|
|
type StructuredDataInput,
|
|
} from "./structured-data";
|
|
|
|
const input: StructuredDataInput = {
|
|
siteUrl: "https://example.com",
|
|
pageUrl: "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",
|
|
},
|
|
catalogName: "Services",
|
|
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",
|
|
inLanguage: "en",
|
|
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("describes the page in its own language: questions, catalogue and website", () => {
|
|
const data = buildStructuredData({
|
|
...input,
|
|
pageUrl: "https://example.com/de",
|
|
language: "de",
|
|
catalogName: "Leistungen",
|
|
});
|
|
expect(node(data, "FAQPage")).toMatchObject({
|
|
"@id": "https://example.com/de#faq",
|
|
inLanguage: "de",
|
|
});
|
|
expect(node(data, "ProfessionalService")).toMatchObject({
|
|
hasOfferCatalog: { name: "Leistungen" },
|
|
});
|
|
expect(node(data, "WebSite")).toMatchObject({ inLanguage: "de" });
|
|
});
|
|
|
|
it("names the same person, business and website on the page of every language", () => {
|
|
const data = buildStructuredData({
|
|
...input,
|
|
pageUrl: "https://example.com/de",
|
|
language: "de",
|
|
});
|
|
expect(node(data, "WebSite")).toMatchObject({
|
|
"@id": "https://example.com/#website",
|
|
url: "https://example.com",
|
|
});
|
|
expect(node(data, "Person")).toMatchObject({
|
|
"@id": "https://example.com/#person",
|
|
url: "https://example.com",
|
|
});
|
|
expect(node(data, "ProfessionalService")).toMatchObject({
|
|
"@id": "https://example.com/#business",
|
|
url: "https://example.com",
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|