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:
@@ -1,9 +1,8 @@
|
||||
export const metadata = {
|
||||
title: "404 - Seite nicht gefunden | Marc Mintel",
|
||||
description: "Diese Seite konnte leider nicht gefunden werden.",
|
||||
title: "Page not found",
|
||||
description: "This page does not exist.",
|
||||
};
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/src/components/Button";
|
||||
|
||||
export default function NotFound() {
|
||||
@@ -14,19 +13,15 @@ export default function NotFound() {
|
||||
Error 404
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
System-Anomalie.
|
||||
Page not found.
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
Die angeforderte URL existiert nicht in dieser Zeitleiste.
|
||||
Möglicherweise wurde die Seite verschoben oder gelöscht.
|
||||
This address does not exist. The page may have been moved or removed.
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href="/" variant="primary">
|
||||
Zurück zur Basis
|
||||
</Button>
|
||||
<Button href="/blog" variant="outline">
|
||||
Aktuelle Artikel lesen
|
||||
Back to the homepage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 185 KiB |
@@ -1,14 +1,11 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/src/content/site";
|
||||
|
||||
/** The site is a single page. */
|
||||
/**
|
||||
* The site is a single page; the legal pages are not meant for search results.
|
||||
* No modification date: it would have to be the date the content last changed,
|
||||
* and a date that moves with every build teaches search engines to ignore it.
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://mintel.me";
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
return [{ url: `${SITE_URL}/` }];
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { INDEXING, SITE_URL } from "@/src/content/site";
|
||||
import { robotsFile } from "@/src/domain/indexing";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const { rules, sitemap } = robotsFile(INDEXING, SITE_URL);
|
||||
return {
|
||||
rules: {
|
||||
...rules,
|
||||
disallow:
|
||||
typeof rules.disallow === "string"
|
||||
? rules.disallow
|
||||
: [...rules.disallow],
|
||||
},
|
||||
sitemap,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://mintel.me/sitemap.xml
|
||||
@@ -1,41 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
const QUESTIONS = [
|
||||
{
|
||||
q: "I am not technical. Is that a problem?",
|
||||
a: "No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
|
||||
},
|
||||
{
|
||||
q: "How long am I committed?",
|
||||
a: "Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
|
||||
},
|
||||
{
|
||||
q: "Who owns what you build?",
|
||||
a: "You do. Code and design belong to you, and you can hand them to another developer.",
|
||||
},
|
||||
{
|
||||
q: "Can you work with our existing team and code?",
|
||||
a: "Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
|
||||
},
|
||||
{
|
||||
q: "How do I reach you?",
|
||||
a: "By email or video call. I work remotely and am available during European business hours.",
|
||||
},
|
||||
{
|
||||
q: "What do you not do?",
|
||||
a: "Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
|
||||
},
|
||||
] as const;
|
||||
import { FAQ } from "@/src/content/faq";
|
||||
|
||||
export const Faq: React.FC = () => (
|
||||
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
|
||||
{QUESTIONS.map((item) => (
|
||||
{FAQ.map((item) => (
|
||||
<details
|
||||
key={item.q}
|
||||
key={item.question}
|
||||
className="group border-t border-slate-200 py-6 open:pb-8"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl [&::-webkit-details-marker]:hidden">
|
||||
{item.q}
|
||||
{item.question}
|
||||
<span
|
||||
className="mt-1 shrink-0 font-mono text-xl text-slate-400 transition-transform duration-300 group-open:rotate-45"
|
||||
aria-hidden="true"
|
||||
@@ -44,7 +18,7 @@ export const Faq: React.FC = () => (
|
||||
</span>
|
||||
</summary>
|
||||
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
|
||||
{item.a}
|
||||
{item.answer}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const HeroStatement: React.FC = () => (
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-10 md:mt-14 md:grid-cols-12 md:items-end md:gap-12">
|
||||
<p className="m-0 max-w-2xl font-sans text-xl leading-snug text-slate-500 md:col-span-7 md:text-3xl">
|
||||
Freelance developer with 15 years of experience.{" "}
|
||||
Freelance web developer with 15 years of experience.{" "}
|
||||
<span className="text-slate-900">
|
||||
I build web apps, interfaces and websites.
|
||||
</span>
|
||||
|
||||
@@ -1,45 +1,58 @@
|
||||
import { TECH } from "@/src/content/about";
|
||||
import { FAQ } from "@/src/content/faq";
|
||||
import { SERVICE_PATHS } from "@/src/content/paths";
|
||||
import {
|
||||
JOB_TITLE,
|
||||
OWNER,
|
||||
PROFILES,
|
||||
SITE_DESCRIPTION,
|
||||
SITE_LANGUAGE,
|
||||
SITE_NAME,
|
||||
SITE_URL,
|
||||
} from "@/src/content/site";
|
||||
import {
|
||||
buildStructuredData,
|
||||
serializeJsonLd,
|
||||
} from "@/src/domain/structured-data";
|
||||
|
||||
const STRUCTURED_DATA = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfessionalService",
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
const [postalCode = "", ...locality] = OWNER.city.split(" ");
|
||||
|
||||
const STRUCTURED_DATA = buildStructuredData({
|
||||
siteUrl: SITE_URL,
|
||||
siteName: SITE_NAME,
|
||||
language: SITE_LANGUAGE,
|
||||
description: SITE_DESCRIPTION,
|
||||
image: `${SITE_URL}/marc-mintel.png`,
|
||||
imageUrl: `${SITE_URL}/marc-mintel.png`,
|
||||
person: {
|
||||
name: OWNER.name,
|
||||
jobTitle: JOB_TITLE,
|
||||
profiles: PROFILES,
|
||||
knowsAbout: [
|
||||
"Web applications",
|
||||
"User interfaces",
|
||||
"Websites",
|
||||
"Process automation",
|
||||
"Web3 frontends",
|
||||
...TECH.flatMap((group) => group.items),
|
||||
],
|
||||
},
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: OWNER.street,
|
||||
postalCode: OWNER.city.split(" ")[0],
|
||||
addressLocality: OWNER.city.split(" ").slice(1).join(" "),
|
||||
addressCountry: "DE",
|
||||
street: OWNER.street,
|
||||
postalCode,
|
||||
locality: locality.join(" "),
|
||||
countryCode: "DE",
|
||||
},
|
||||
founder: {
|
||||
"@type": "Person",
|
||||
name: SITE_NAME,
|
||||
jobTitle: "Freelance developer",
|
||||
url: SITE_URL,
|
||||
},
|
||||
knowsAbout: [
|
||||
"Web applications",
|
||||
"User interfaces",
|
||||
"Websites",
|
||||
"Process automation",
|
||||
"Web3 frontends",
|
||||
],
|
||||
} as const;
|
||||
services: SERVICE_PATHS.map((path) => ({
|
||||
name: path.service,
|
||||
description: path.intro,
|
||||
})),
|
||||
faq: FAQ,
|
||||
});
|
||||
|
||||
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
|
||||
/** Built server-side from the page content; see src/domain/structured-data.ts. */
|
||||
export const HomeJsonLd: React.FC = () => (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(STRUCTURED_DATA).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(STRUCTURED_DATA) }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export type FaqItem = {
|
||||
readonly question: string;
|
||||
readonly answer: string;
|
||||
};
|
||||
|
||||
/** Shown in the questions section and passed to search engines as structured data. */
|
||||
export const FAQ: readonly FaqItem[] = [
|
||||
{
|
||||
question: "I am not technical. Is that a problem?",
|
||||
answer:
|
||||
"No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
|
||||
},
|
||||
{
|
||||
question: "How long am I committed?",
|
||||
answer:
|
||||
"Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
|
||||
},
|
||||
{
|
||||
question: "Who owns what you build?",
|
||||
answer:
|
||||
"You do. Code and design belong to you, and you can hand them to another developer.",
|
||||
},
|
||||
{
|
||||
question: "Can you work with our existing team and code?",
|
||||
answer:
|
||||
"Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
|
||||
},
|
||||
{
|
||||
question: "How do I reach you?",
|
||||
answer:
|
||||
"By email or video call. I work remotely and am available during European business hours.",
|
||||
},
|
||||
{
|
||||
question: "What do you not do?",
|
||||
answer:
|
||||
"Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
|
||||
},
|
||||
];
|
||||
@@ -16,6 +16,8 @@ export type PathPrice = {
|
||||
|
||||
export type ServicePath = {
|
||||
readonly id: string;
|
||||
/** The service in neutral words, as search engines list it. */
|
||||
readonly service: string;
|
||||
/** How the visitor would say it. */
|
||||
readonly need: string;
|
||||
readonly forWhom: string;
|
||||
@@ -34,6 +36,7 @@ export type ServicePath = {
|
||||
export const SERVICE_PATHS: readonly ServicePath[] = [
|
||||
{
|
||||
id: "developer",
|
||||
service: "Web app and frontend development",
|
||||
need: "We need a developer for our product",
|
||||
forWhom: "Startups, product teams, agencies, Web3 teams",
|
||||
headline: "An experienced developer who joins your project.",
|
||||
@@ -76,6 +79,7 @@ export const SERVICE_PATHS: readonly ServicePath[] = [
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
service: "Code review and stabilisation of web apps",
|
||||
need: "Our app needs to become reliable",
|
||||
forWhom: "Teams whose app was built quickly and is now hard to change",
|
||||
headline: "A week to look at your app and fix what is most urgent.",
|
||||
@@ -115,6 +119,7 @@ export const SERVICE_PATHS: readonly ServicePath[] = [
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
service: "Process automation and system integration",
|
||||
need: "We want to automate a process",
|
||||
forWhom:
|
||||
"Companies where people copy data between tools or repeat the same steps",
|
||||
@@ -156,6 +161,7 @@ export const SERVICE_PATHS: readonly ServicePath[] = [
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
service: "Website design and development",
|
||||
need: "I just need a website",
|
||||
forWhom: "Companies and self-employed people who want it taken care of",
|
||||
headline: "A website, built and looked after for you.",
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { parseEmailParts, type EmailParts } from "@/src/domain/email";
|
||||
import { indexingFor } from "@/src/domain/indexing";
|
||||
|
||||
export const SITE_NAME = "Marc Mintel";
|
||||
export const SITE_URL = "https://mintel.me";
|
||||
|
||||
/** One wording for the browser title, search results and link previews. */
|
||||
export const SITE_TITLE =
|
||||
"Marc Mintel — Freelance developer for web apps and websites";
|
||||
"Freelance web developer for web apps & websites | Marc Mintel";
|
||||
export const SITE_DESCRIPTION =
|
||||
"Freelance developer with 15 years of experience. I build web apps, interfaces and websites, and automate processes between your systems.";
|
||||
"Freelance web developer with 15 years of experience. I build web apps, interfaces and websites with React, Next.js and TypeScript, and automate processes.";
|
||||
export const SITE_LANGUAGE = "en";
|
||||
export const JOB_TITLE = "Freelance web developer";
|
||||
|
||||
/**
|
||||
* Public profiles of the owner (LinkedIn, GitHub, ...). Search engines use them to
|
||||
* connect this site with the same person elsewhere. Only add profiles the owner confirmed.
|
||||
*/
|
||||
export const PROFILES: readonly string[] = [];
|
||||
|
||||
/** Only the production deployment may appear in search results. Set by the build. */
|
||||
export const INDEXING = indexingFor(process.env.NEXT_PUBLIC_TARGET);
|
||||
|
||||
// The contact address, kept in parts on purpose: the complete address must never
|
||||
// appear as one string in the page source (see src/components/EmailLink.tsx).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { indexingFor, robotsFile, robotsMeta } from "./indexing";
|
||||
|
||||
describe("indexingFor", () => {
|
||||
it("lets search engines index the production deployment", () => {
|
||||
expect(indexingFor("production")).toBe("indexable");
|
||||
});
|
||||
|
||||
it.each(["testing", "staging", "branch"])(
|
||||
"blocks the %s deployment, which is a copy of the real site",
|
||||
(target) => {
|
||||
expect(indexingFor(target)).toBe("blocked");
|
||||
},
|
||||
);
|
||||
|
||||
// A production build that lost its target must not drop out of the search index.
|
||||
it.each([
|
||||
["a missing target", undefined],
|
||||
["an empty target", ""],
|
||||
["an unknown target", "preview"],
|
||||
["a target in another case", "Testing"],
|
||||
["a value that is not a string", 1],
|
||||
])("stays indexable for %s", (_case, target) => {
|
||||
expect(indexingFor(target)).toBe("indexable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("robotsFile", () => {
|
||||
it("allows everything except the analytics and error proxies, and names the sitemap", () => {
|
||||
expect(robotsFile("indexable", "https://mintel.me")).toEqual({
|
||||
rules: { userAgent: "*", allow: "/", disallow: ["/stats/", "/errors/"] },
|
||||
sitemap: "https://mintel.me/sitemap.xml",
|
||||
});
|
||||
});
|
||||
|
||||
it("disallows everything on a blocked deployment and names no sitemap", () => {
|
||||
expect(robotsFile("blocked", "https://mintel.me")).toEqual({
|
||||
rules: { userAgent: "*", disallow: "/" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("robotsMeta", () => {
|
||||
it("allows indexing with full snippets and large image previews", () => {
|
||||
expect(robotsMeta("indexable")).toEqual({
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-snippet": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-video-preview": -1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forbids indexing and following on a blocked deployment", () => {
|
||||
expect(robotsMeta("blocked")).toEqual({ index: false, follow: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Whether search engines may index a deployment. Only the production site belongs
|
||||
* in search results; the testing, staging and branch deployments are copies of it.
|
||||
*/
|
||||
export type Indexing = "indexable" | "blocked";
|
||||
|
||||
export type RobotsFile = {
|
||||
readonly rules: {
|
||||
readonly userAgent: string;
|
||||
readonly allow?: string;
|
||||
readonly disallow: string | readonly string[];
|
||||
};
|
||||
readonly sitemap?: string;
|
||||
};
|
||||
|
||||
export type RobotsMeta = {
|
||||
readonly index: boolean;
|
||||
readonly follow: boolean;
|
||||
readonly googleBot?: {
|
||||
readonly index: boolean;
|
||||
readonly follow: boolean;
|
||||
readonly "max-snippet": number;
|
||||
readonly "max-image-preview": "large";
|
||||
readonly "max-video-preview": number;
|
||||
};
|
||||
};
|
||||
|
||||
const BLOCKED_TARGETS: readonly unknown[] = ["testing", "staging", "branch"];
|
||||
|
||||
/** Paths that proxy analytics and error reports; nothing there is a page. */
|
||||
const PROXY_PATHS: readonly string[] = ["/stats/", "/errors/"];
|
||||
|
||||
/**
|
||||
* Only the deployments known to be copies are blocked. Anything else stays
|
||||
* indexable, so a production build that lost its target does not drop out of
|
||||
* the search index.
|
||||
*/
|
||||
export function indexingFor(target: unknown): Indexing {
|
||||
return BLOCKED_TARGETS.includes(target) ? "blocked" : "indexable";
|
||||
}
|
||||
|
||||
export function robotsFile(indexing: Indexing, siteUrl: string): RobotsFile {
|
||||
if (indexing === "blocked") {
|
||||
return { rules: { userAgent: "*", disallow: "/" } };
|
||||
}
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/", disallow: PROXY_PATHS },
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The robots meta tag. Google gets no limit on snippet length or image preview size. */
|
||||
export function robotsMeta(indexing: Indexing): RobotsMeta {
|
||||
if (indexing === "blocked") return { index: false, follow: false };
|
||||
return {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-snippet": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-video-preview": -1,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Structured data (schema.org JSON-LD) that tells search engines who runs the site,
|
||||
* what is offered and which questions the page answers. Built from the same content
|
||||
* the page shows, so the two cannot drift apart.
|
||||
*/
|
||||
export type StructuredDataInput = {
|
||||
readonly siteUrl: string;
|
||||
readonly siteName: string;
|
||||
readonly language: string;
|
||||
readonly description: string;
|
||||
readonly imageUrl: string;
|
||||
readonly person: {
|
||||
readonly name: string;
|
||||
readonly jobTitle: string;
|
||||
/** Public profiles of the same person elsewhere on the web. */
|
||||
readonly profiles: readonly string[];
|
||||
readonly knowsAbout: readonly string[];
|
||||
};
|
||||
readonly address: {
|
||||
readonly street: string;
|
||||
readonly postalCode: string;
|
||||
readonly locality: string;
|
||||
readonly countryCode: string;
|
||||
};
|
||||
readonly services: readonly {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
}[];
|
||||
readonly faq: readonly {
|
||||
readonly question: string;
|
||||
readonly answer: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type StructuredDataNode = {
|
||||
readonly "@type": string;
|
||||
readonly [property: string]: unknown;
|
||||
};
|
||||
|
||||
export type StructuredData = {
|
||||
readonly "@context": "https://schema.org";
|
||||
readonly "@graph": readonly StructuredDataNode[];
|
||||
};
|
||||
|
||||
export function buildStructuredData(
|
||||
input: StructuredDataInput,
|
||||
): StructuredData {
|
||||
const { siteUrl, siteName, description, imageUrl, person, address } = input;
|
||||
const personRef = { "@id": `${siteUrl}/#person` };
|
||||
const businessRef = { "@id": `${siteUrl}/#business` };
|
||||
|
||||
const website: StructuredDataNode = {
|
||||
"@type": "WebSite",
|
||||
"@id": `${siteUrl}/#website`,
|
||||
url: siteUrl,
|
||||
name: siteName,
|
||||
description,
|
||||
inLanguage: input.language,
|
||||
publisher: personRef,
|
||||
};
|
||||
|
||||
const personNode: StructuredDataNode = {
|
||||
"@type": "Person",
|
||||
...personRef,
|
||||
name: person.name,
|
||||
url: siteUrl,
|
||||
image: imageUrl,
|
||||
jobTitle: person.jobTitle,
|
||||
knowsAbout: person.knowsAbout,
|
||||
...(person.profiles.length > 0 ? { sameAs: person.profiles } : {}),
|
||||
};
|
||||
|
||||
const business: StructuredDataNode = {
|
||||
"@type": "ProfessionalService",
|
||||
...businessRef,
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
description,
|
||||
image: imageUrl,
|
||||
founder: personRef,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: address.street,
|
||||
postalCode: address.postalCode,
|
||||
addressLocality: address.locality,
|
||||
addressCountry: address.countryCode,
|
||||
},
|
||||
hasOfferCatalog: {
|
||||
"@type": "OfferCatalog",
|
||||
name: "Services",
|
||||
itemListElement: input.services.map((service) => ({
|
||||
"@type": "Offer",
|
||||
itemOffered: {
|
||||
"@type": "Service",
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
provider: businessRef,
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const faq: readonly StructuredDataNode[] =
|
||||
input.faq.length > 0
|
||||
? [
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
"@id": `${siteUrl}/#faq`,
|
||||
mainEntity: input.faq.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [website, personNode, business, ...faq],
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON for a script tag. "<" is escaped so that no text can close the tag. */
|
||||
export function serializeJsonLd(data: StructuredData): string {
|
||||
return JSON.stringify(data).replace(/</g, "\\u003c");
|
||||
}
|
||||
Reference in New Issue
Block a user