From 0519023f07fd907fd86669d2d97560e2b2c2fb40 Mon Sep 17 00:00:00 2001 From: Kanav Jain Date: Thu, 8 Oct 2026 00:48:31 -0500 Subject: [PATCH] feat(seo): emit canonical URLs, Open Graph tags, JSON-LD and sitemap.xml Every built page now carries a canonical URL at the trailing-slash address GitHub Pages serves it from, Open Graph and twitter:card tags, and one JSON-LD block: WebSite plus SoftwareSourceCode on the home page, TechArticle plus BreadcrumbList on doc pages. The build also writes dist/sitemap.xml listing every page. It has no because the deploy checkout is shallow and cannot date pages from git history. Titles and the meta description are unchanged. Co-Authored-By: Claude Opus 5.5 --- build.mjs | 128 +++++++++++++++++++++++++++++-- tests/seo.test.ts | 187 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 tests/seo.test.ts diff --git a/build.mjs b/build.mjs index 72b2c50..5e30517 100644 --- a/build.mjs +++ b/build.mjs @@ -9,6 +9,15 @@ const DOCS_DIR = join(__dirname, "docs"); const DIST_DIR = join(__dirname, "dist"); const ASSETS_DIR = join(__dirname, "assets"); const BASE = process.env.BASE || "/refract-docs/"; +// Absolute address of the published site. Canonical URLs, og:url, JSON-LD +// and the sitemap point here even when BASE is overridden for a local build. +const SITE_URL = ( + process.env.SITE_URL || "https://refract-org.github.io/refract-docs/" +).replace(/\/?$/, "/"); +const SITE_NAME = "Refract"; +const SITE_DESCRIPTION = + "Refract — the open claim-history layer for public knowledge. Deterministic event stream of claims, sources, and disputes across revision histories."; +const SOURCE_REPOSITORY = "https://github.com/refract-org/refract"; function assetVersion() { if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA.slice(0, 12); @@ -28,7 +37,7 @@ const ASSET_VERSION = assetVersion(); let NAV = []; -function resolveTitle(slug) { +function resolveTitle(slug, fallback = basename(slug)) { for (const item of NAV) { if (item.slug === slug) return item.title; if (item.children) { @@ -37,11 +46,113 @@ function resolveTitle(slug) { } } } - return basename(slug); + return fallback; } -function slugHref(slug) { - return slug === "index" ? BASE : `${BASE}${slug}/`; +// GitHub Pages serves dist//index.html at / and 301-redirects the +// slash-less form, so the trailing-slash URL is the one to link and canonicalize. +function slugHref(slug, base = BASE) { + return slug === "index" ? base : `${base}${slug}/`; +} + +// Safe in HTML attribute values and in XML text alike. +function escapeMarkup(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function structuredData(title, slug) { + const url = slugHref(slug, SITE_URL); + const website = { + "@type": "WebSite", + "@id": `${SITE_URL}#website`, + name: SITE_NAME, + url: SITE_URL, + }; + if (slug === "index") { + return { + "@context": "https://schema.org", + "@graph": [ + { + ...website, + description: SITE_DESCRIPTION, + inLanguage: "en", + about: { "@id": `${SITE_URL}#source` }, + }, + { + "@type": "SoftwareSourceCode", + "@id": `${SITE_URL}#source`, + name: SITE_NAME, + codeRepository: SOURCE_REPOSITORY, + }, + ], + }; + } + return { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "TechArticle", + "@id": `${url}#article`, + headline: title, + description: SITE_DESCRIPTION, + url, + mainEntityOfPage: url, + inLanguage: "en", + isPartOf: website, + }, + { + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: resolveTitle("index"), + item: SITE_URL, + }, + { + "@type": "ListItem", + position: 2, + name: resolveTitle(slug, title), + item: url, + }, + ], + }, + ], + }; +} + +function renderSeoHead(title, slug) { + const url = escapeMarkup(slugHref(slug, SITE_URL)); + // "<" is escaped so no title can close the script element early. + const jsonLd = JSON.stringify(structuredData(title, slug)).replace( + / + + + + + + + `; +} + +function renderSitemap(slugs) { + const urls = slugs.map((slug) => slugHref(slug, SITE_URL)).sort(); + const entries = urls + .map((url) => ` ${escapeMarkup(url)}`) + .join("\n"); + return ` + +${entries} + +`; } function renderNav(currentSlug) { @@ -129,7 +240,8 @@ function wrapHTML(title, content, currentSlug, headings = []) { ${title} — Refract - + + ${renderSeoHead(title, currentSlug)} @@ -590,6 +702,12 @@ async function build() { join(DIST_DIR, "search-index.json"), JSON.stringify(searchIndex, null, 2), ); + // No : the deploy checkout is shallow, so git history cannot date + // individual pages there. + await writeFile( + join(DIST_DIR, "sitemap.xml"), + renderSitemap(files.map((file) => file.slug)), + ); for (const asset of assets) { const dest = join(DIST_DIR, asset.rel); diff --git a/tests/seo.test.ts b/tests/seo.test.ts new file mode 100644 index 0000000..2557a13 --- /dev/null +++ b/tests/seo.test.ts @@ -0,0 +1,187 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, it } from "vitest"; + +const ROOT_DIR = resolve(__dirname, ".."); +const DIST_DIR = join(ROOT_DIR, "dist"); +const SITE_URL = "https://refract-org.github.io/refract-docs/"; +const execFileAsync = promisify(execFile); + +type Page = { slug: string; title: string; html: string }; +type JsonLdNode = Record & { "@type": string }; + +function canonicalFor(slug: string): string { + return slug === "index" ? SITE_URL : `${SITE_URL}${slug}/`; +} + +function decodeEntities(text: string): string { + return text + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&"); +} + +function metaContents(html: string, attr: string, key: string): string[] { + const pattern = new RegExp(``, "g"); + return Array.from(html.matchAll(pattern), (match) => + decodeEntities(match[1]), + ); +} + +function canonicals(html: string): string[] { + return Array.from( + html.matchAll(//g), + (match) => decodeEntities(match[1]), + ); +} + +function jsonLdGraph(html: string): JsonLdNode[] { + const blocks = Array.from( + html.matchAll(/