diff --git a/astro.config.ts b/astro.config.ts index 007b6e8b..5c86621b 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -101,7 +101,10 @@ export default defineConfig({ lang: "en", }, }, - routeMiddleware: "./src/plugins/toc-formatting-middleware.ts", + routeMiddleware: [ + "./src/plugins/toc-formatting-middleware.ts", + "./src/plugins/techarticle-json-ld-middleware.ts", + ], components: { Head: "./src/components/Head.astro", }, diff --git a/src/plugins/techarticle-json-ld-middleware.ts b/src/plugins/techarticle-json-ld-middleware.ts new file mode 100644 index 00000000..e99673eb --- /dev/null +++ b/src/plugins/techarticle-json-ld-middleware.ts @@ -0,0 +1,53 @@ +import { defineRouteMiddleware } from "@astrojs/starlight/route-data"; + +const ORGANIZATION = { + "@type": "Organization", + name: "System76", + url: "https://system76.com", +}; + +// Adds schema.org TechArticle structured data (JSON-LD) to every docs page. +export const onRequest = defineRouteMiddleware((context) => { + const { starlightRoute } = context.locals; + + // The 404 page and the landing pages (home has an empty id, plus archive) aren't articles. + if (["404", "", "archive"].includes(starlightRoute.id)) { + return; + } + + // Starlight already puts the canonical URL in the head. Reuse it so the URL carries the right + // host and the /tech-docs base on every environment. Without a canonical (no `site` set), skip. + const canonical = starlightRoute.head.find( + (tag) => tag.tag === "link" && tag.attrs?.rel === "canonical", + )?.attrs?.href; + + if (typeof canonical !== "string") { + return; + } + + const { title, description } = starlightRoute.entry.data; + const { lastUpdated } = starlightRoute; + + const jsonLd = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: title, + description, + url: canonical, + mainEntityOfPage: { "@type": "WebPage", "@id": canonical }, + // Taken from git history when available. There is no publish date to use. + dateModified: lastUpdated?.toISOString(), + inLanguage: starlightRoute.lang, + author: ORGANIZATION, + publisher: ORGANIZATION, + }; + + starlightRoute.head.push({ + tag: "script", + attrs: { type: "application/ld+json" }, + // Drop empty fields, and escape "<" so page text can't close the script tag. + content: JSON.stringify( + Object.fromEntries(Object.entries(jsonLd).filter(([, value]) => value)), + ).replace(/