From 937ffe4f8c6a46d3afe535726e9cd3770df961ac Mon Sep 17 00:00:00 2001 From: Kanav Jain Date: Thu, 8 Oct 2026 16:31:30 -0500 Subject: [PATCH 1/5] fix(links): give relative doc links their trailing slash GitHub Pages serves dist//index.html at / and 301-redirects the slash-less form. The markdown link resolver only added the slash when a link ended in .md, so seven built links redirected: `(cli)` and `(./cli)` in mcp, schema and sdk, `(schema#version-compatibility)` in downstream, `(python-sdk)` and `(visualizer)` on the home page, and `(tutorials/refract-ui)` in visualizer. rewriteLink now resolves every relative link to a page (cli, ./cli, cli/, cli.md, ../index.md) to the page's trailing-slash URL. A path whose last segment has a file extension (.svg, .png, .ipynb) is an asset and keeps its form. Links with any URL scheme, such as mailto:, are left alone; before, only http(s) links were. Tests: - a built-site test fails if any internal link, including the canonical URL, points at a directory without the trailing slash; - unit tests cover rewriteLink's page, asset and external cases. build.mjs now builds only when run as a script, so tests can import it. Co-Authored-By: Claude Opus 5.5 --- build.mjs | 43 +++++++++------ tests/links.test.ts | 124 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 137 insertions(+), 30 deletions(-) diff --git a/build.mjs b/build.mjs index a1dd053..bb250da 100644 --- a/build.mjs +++ b/build.mjs @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join, posix } from "node:path"; import { fileURLToPath } from "node:url"; @@ -624,24 +625,27 @@ function wrapHTML(title, description, content, currentSlug, headings = []) { `; } -function rewriteLink(href, sourceDir = "") { +// Resolves a markdown link against the source file's directory. A link to a +// page (`cli.md`, `cli`, `./cli/`, `../index.md`) becomes the page's +// trailing-slash URL, so it never 301-redirects. A path whose last segment has +// a file extension (`.svg`, `.png`, `.ipynb`) is an asset and keeps its form. +export function rewriteLink(href, sourceDir = "") { if (!href) return href; - if (href.startsWith("http") || href.startsWith("#")) return href; + if (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("#")) return href; const [, rawPath = "", suffix = ""] = href.match(/^([^?#]*)([?#].*)?$/) ?? []; if (!rawPath) return href; - let path = rawPath.replace(/\.md$/, "/"); - if (sourceDir && !path.startsWith("/")) { - path = `${sourceDir}/${path}`; + const path = posix + .normalize( + rawPath.startsWith("/") ? rawPath : posix.join("/", sourceDir, rawPath), + ) + .replace(/^\/+|\/+$/g, ""); + if (posix.extname(path) && !path.endsWith(".md")) { + return `${BASE}${path}${suffix}`; } - - path = posix.normalize(path); - if (path === "." || path === "index") return `${BASE}${suffix}`; - if (path === "index/") return `${BASE}${suffix}`; - - const normalizedPath = path.startsWith("/") ? path.slice(1) : path; - return `${BASE}${normalizedPath}${suffix}`; + const slug = path.replace(/\.md$/, "").replace(/(^|\/)index$/, ""); + return `${slugHref(slug || "index")}${suffix}`; } async function collectFiles(dir, base = "") { @@ -768,7 +772,14 @@ async function build() { } } -build().catch((err) => { - console.error(err); - process.exit(1); -}); +// Build only when run as a script, so tests can import rewriteLink. Both sides +// are real paths, so a symlinked checkout still builds. +if ( + process.argv[1] && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +) { + build().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/tests/links.test.ts b/tests/links.test.ts index 04d151f..88cfa7a 100644 --- a/tests/links.test.ts +++ b/tests/links.test.ts @@ -1,15 +1,17 @@ import { execFile } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { marked } from "marked"; import { beforeAll, describe, expect, it } from "vitest"; +import { rewriteLink } from "../build.mjs"; const ROOT_DIR = resolve(__dirname, ".."); const DOCS_DIR = join(ROOT_DIR, "docs"); const DIST_DIR = join(ROOT_DIR, "dist"); const SITE_BASE = "/refract-docs"; +const SITE_URL = `https://refract-org.github.io${SITE_BASE}/`; const execFileAsync = promisify(execFile); // Helper to recursively find all .md files @@ -211,27 +213,36 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -async function checkGeneratedLink( - sourceFile: string, - href: string, -): Promise<{ isValid: boolean; resolvedPath: string }> { - if (isExternalLink(href) || href.startsWith("#")) { - return { isValid: true, resolvedPath: href }; - } +// The URL path a link in a built page points at ("/refract-docs/cli/"), or +// null for an external link or a same-page anchor. Absolute links to the +// published site, such as the canonical URL, count as internal. +function sitePathOf(sourceFile: string, href: string): string | null { + const local = href.startsWith(SITE_URL) + ? href.slice(SITE_URL.length - SITE_BASE.length - 1) + : href; + if (isExternalLink(local) || local.startsWith("#")) return null; - const [pathAndQuery, hash] = href.split("#"); - const [pathPart] = pathAndQuery.split("?"); - if (!pathPart) { - return { isValid: true, resolvedPath: href }; - } + const [pathPart] = local.split(/[?#]/); + if (!pathPart) return null; const relativeDir = dirname(sourceFile) .replace(DIST_DIR, "") .replaceAll("\\", "/"); const pagePath = `${SITE_BASE}${relativeDir === "/" ? "" : relativeDir}/`; - const urlPath = normalizeUrlPath( + return normalizeUrlPath( pathPart.startsWith("/") ? pathPart : `${pagePath}${pathPart}`, ); +} + +async function checkGeneratedLink( + sourceFile: string, + href: string, +): Promise<{ isValid: boolean; resolvedPath: string }> { + const urlPath = sitePathOf(sourceFile, href); + if (urlPath === null) { + return { isValid: true, resolvedPath: href }; + } + const hash = href.split("#")[1]; if (!(urlPath === `${SITE_BASE}/` || urlPath.startsWith(`${SITE_BASE}/`))) { return { isValid: false, resolvedPath: urlPath }; @@ -322,4 +333,89 @@ describe("Generated site links", async () => { `Found broken generated links:\n${invalidLinks.join("\n")}`, ).toEqual([]); }); + + // GitHub Pages serves dist//index.html at / and answers the + // slash-less with a 301 redirect. + it("links to pages at their trailing-slash URL, so no internal link redirects", async () => { + const redirects: string[] = []; + let checked = 0; + + for (const file of await findHtmlFiles(DIST_DIR)) { + const html = await readFile(file, "utf-8"); + for (const href of extractLinks(html)) { + const urlPath = sitePathOf(file, href); + if (urlPath === null) continue; + checked++; + if (urlPath.endsWith("/")) continue; + const diskPath = join(DIST_DIR, urlPath.slice(SITE_BASE.length)); + if (existsSync(diskPath) && statSync(diskPath).isDirectory()) { + redirects.push(`${file.replace(`${DIST_DIR}/`, "")}: ${href}`); + } + } + } + + expect(checked).toBeGreaterThan(100); + expect( + redirects, + `These internal links redirect:\n${redirects.join("\n")}`, + ).toEqual([]); + }); +}); + +describe("rewriteLink", () => { + it.each([ + ["cli", "", "/refract-docs/cli/"], + ["./cli", "", "/refract-docs/cli/"], + ["cli/", "", "/refract-docs/cli/"], + ["cli.md", "", "/refract-docs/cli/"], + [ + "schema#version-compatibility", + "", + "/refract-docs/schema/#version-compatibility", + ], + [ + "schema.md#version-compatibility", + "", + "/refract-docs/schema/#version-compatibility", + ], + ["tutorials/refract-ui", "", "/refract-docs/tutorials/refract-ui/"], + ["fandom-canon.md", "tutorials", "/refract-docs/tutorials/fandom-canon/"], + ["../analytics.md", "tutorials", "/refract-docs/analytics/"], + ["../index.md", "tutorials", "/refract-docs/"], + ["index.md", "", "/refract-docs/"], + ["./", "", "/refract-docs/"], + ["/quickstart", "tutorials", "/refract-docs/quickstart/"], + ["cli?x=1", "", "/refract-docs/cli/?x=1"], + ])("links %s (from docs/%s) to the page URL %s", (href, dir, expected) => { + expect(rewriteLink(href, dir)).toBe(expected); + }); + + it.each([ + ["mcp-sequence.svg", "", "/refract-docs/mcp-sequence.svg"], + [ + "assets/refract-ui-screenshot.png", + "", + "/refract-docs/assets/refract-ui-screenshot.png", + ], + [ + "notebooks/model-evaluation.ipynb", + "", + "/refract-docs/notebooks/model-evaluation.ipynb", + ], + ["../boundary.svg", "tutorials", "/refract-docs/boundary.svg"], + ])( + "leaves the asset link %s (from docs/%s) without a slash", + (href, dir, expected) => { + expect(rewriteLink(href, dir)).toBe(expected); + }, + ); + + it.each([ + "https://example.org/page", + "http://example.org", + "mailto:docs@example.org", + "#anchor", + ])("leaves %s unchanged", (href) => { + expect(rewriteLink(href)).toBe(href); + }); }); From e47a8d3c4e30507441e0e87ce513bf8eecb9b3f0 Mon Sep 17 00:00:00 2001 From: Kanav Jain Date: Thu, 8 Oct 2026 16:31:48 -0500 Subject: [PATCH 2/5] feat(seo): add a 1200x630 Open Graph card to every page No page had an og:image, and the PNGs in docs/assets are placeholders. The build now renders dist/social-card.png: the site icon and "Refract", the positioning line, and the site address, in the colors from assets/style.css. It is drawn from SITE_NAME and SITE_DESCRIPTION, so the card changes when the positioning line does. Every page's head gets og:image (absolute URL), og:image:width, og:image:height, og:image:alt, and twitter:card summary_large_image. social-card.mjs renders an SVG with @resvg/resvg-js. The site's font stack starts with system-ui, which resolves to the reader's OS face (SF Pro, Segoe UI) and cannot be redistributed in an image, so the card uses Roboto, the stack's OFL-licensed face, from @expo-google-fonts/roboto. System fonts are never loaded, so every machine renders the same PNG. The description is word-wrapped by measuring it in that font; a description too long for the card fails the build instead of clipping. Tests cover the head tags on every page, the published PNG's size, line wrapping, SVG escaping, and the overflow error. Co-Authored-By: Claude Opus 5.5 --- build.mjs | 19 +++- package-lock.json | 237 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 + social-card.mjs | 119 +++++++++++++++++++++++ tests/seo.test.ts | 85 ++++++++++++++++- 5 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 social-card.mjs diff --git a/build.mjs b/build.mjs index bb250da..984542c 100644 --- a/build.mjs +++ b/build.mjs @@ -4,6 +4,7 @@ import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join, posix } from "node:path"; import { fileURLToPath } from "node:url"; import { marked } from "marked"; +import { CARD_HEIGHT, CARD_WIDTH, renderSocialCard } from "./social-card.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DOCS_DIR = join(__dirname, "docs"); @@ -19,6 +20,10 @@ const SITE_NAME = "Refract"; const SITE_DESCRIPTION = "A deterministic observation engine for revision histories. It reads a page's edits and emits a typed event for each change."; const SOURCE_REPOSITORY = "https://github.com/refract-org/refract"; +// One Open Graph card for every page, rendered at build time from the name +// and description above. +const SOCIAL_CARD_FILE = "social-card.png"; +const SOCIAL_CARD_ALT = `${SITE_NAME}. ${SITE_DESCRIPTION}`; function assetVersion() { if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA.slice(0, 12); @@ -139,7 +144,11 @@ function renderSeoHead(title, description, slug) { - + + + + + `; } @@ -754,6 +763,14 @@ async function build() { join(DIST_DIR, "sitemap.xml"), renderSitemap(files.map((file) => file.slug)), ); + await writeFile( + join(DIST_DIR, SOCIAL_CARD_FILE), + renderSocialCard({ + name: SITE_NAME, + description: SITE_DESCRIPTION, + address: SITE_URL.replace(/^https?:\/\//, "").replace(/\/$/, ""), + }), + ); for (const asset of assets) { const dest = join(DIST_DIR, asset.rel); diff --git a/package-lock.json b/package-lock.json index cff3dab..893bdbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "CC-BY-4.0", "devDependencies": { "@biomejs/biome": "^2.5.15", + "@expo-google-fonts/roboto": "^0.4.3", + "@resvg/resvg-js": "^2.6.2", "husky": "^9.1.7", "lint-staged": "^17.6.0", "marked": "^15.0.12", @@ -56,6 +58,13 @@ "node": ">=14.21.3" } }, + "node_modules/@expo-google-fonts/roboto": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/roboto/-/roboto-0.4.3.tgz", + "integrity": "sha512-kX3wmpmu06fN1yOwYWMdjYJWNUYW6xUiAGeHbvPzzSEb3A3j23JelQ3tAgwQNKgEKRq6wtMmzixy2r7y9c8dsA==", + "dev": true, + "license": "MIT AND OFL-1.1" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "dev": true, @@ -69,6 +78,234 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rolldown/binding-darwin-arm64": { "version": "1.0.2", "cpu": [ diff --git a/package.json b/package.json index f3105d0..5daadb7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.15", + "@expo-google-fonts/roboto": "^0.4.3", + "@resvg/resvg-js": "^2.6.2", "husky": "^9.1.7", "lint-staged": "^17.6.0", "marked": "^15.0.12", diff --git a/social-card.mjs b/social-card.mjs new file mode 100644 index 0000000..fc30031 --- /dev/null +++ b/social-card.mjs @@ -0,0 +1,119 @@ +// Renders the Open Graph card: the site name, the positioning line and the +// site address, in the colors of assets/style.css. +import { createRequire } from "node:module"; +import { Resvg } from "@resvg/resvg-js"; + +const require = createRequire(import.meta.url); + +export const CARD_WIDTH = 1200; +export const CARD_HEIGHT = 630; + +// The site's font stack starts with system-ui, which resolves to the reader's +// operating-system face (SF Pro, Segoe UI); those cannot be redistributed in an +// image. Roboto is the stack's openly licensed face (OFL-1.1). Only these two +// files are loaded, never system fonts, so every machine renders the same PNG. +const FONT = { + fontFiles: [ + require.resolve( + "@expo-google-fonts/roboto/400Regular/Roboto_400Regular.ttf", + ), + require.resolve("@expo-google-fonts/roboto/700Bold/Roboto_700Bold.ttf"), + ], + loadSystemFonts: false, + defaultFontFamily: "Roboto", +}; + +// Custom properties from assets/style.css. +const COLORS = { + background: "#07090f", // --bg-deep + brand: "#ffffff", // .brand + text: "#e2e4ed", // --text + muted: "#7c82a0", // --text-muted + accent: "#2dd4bf", // --accent + rule: "rgba(255, 255, 255, 0.12)", // --border-strong +}; + +const MARGIN = 80; +const TEXT_WIDTH = CARD_WIDTH - 2 * MARGIN; +const NAME = { size: 104, baseline: 196 }; +const LINE = { size: 46, leading: 62, firstBaseline: 304 }; +const FOOTER = { + size: 28, + baseline: CARD_HEIGHT - 72, + rule: CARD_HEIGHT - 132, +}; +export const MAX_LINES = Math.floor( + (FOOTER.rule - LINE.firstBaseline) / LINE.leading, +); + +function escapeXml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function text(content, { x, y, size, weight = 400, fill, spacing = 0 }) { + return `${escapeXml(content)}`; +} + +function textWidth(content, size, weight = 400) { + const svg = `${text(content, { x: 0, y: size, size, weight, fill: "#000" })}`; + return new Resvg(svg, { font: FONT }).getBBox()?.width ?? 0; +} + +// Greedy word wrap, measured in the font the card is drawn in. +export function wrapText(content, size = LINE.size, maxWidth = TEXT_WIDTH) { + const lines = []; + let line = ""; + for (const word of content.trim().split(/\s+/)) { + const candidate = line ? `${line} ${word}` : word; + if (line && textWidth(candidate, size) > maxWidth) { + lines.push(line); + line = word; + } else { + line = candidate; + } + } + if (line) lines.push(line); + return lines; +} + +export function socialCardSvg({ name, description, address }) { + const lines = wrapText(description); + if (lines.length > MAX_LINES) { + throw new Error( + `The social card fits ${MAX_LINES} lines of description; this one wraps to ${lines.length}: ${description}`, + ); + } + // The site icon (the favicon triangle, drawn on a 16-unit grid), sized to + // the cap height of the name. + const capHeight = NAME.size * 0.711; + const scale = capHeight / 12; + const iconWidth = 14 * scale; + const icon = ``; + const body = lines + .map((line, i) => + text(line, { + x: MARGIN, + y: LINE.firstBaseline + i * LINE.leading, + size: LINE.size, + fill: COLORS.text, + }), + ) + .join("\n "); + return ` + + ${icon} + ${text(name, { x: MARGIN + iconWidth + 28, y: NAME.baseline, size: NAME.size, weight: 700, fill: COLORS.brand, spacing: -0.02 * NAME.size })} + ${body} + + ${text(address, { x: MARGIN, y: FOOTER.baseline, size: FOOTER.size, fill: COLORS.muted })} +`; +} + +export function renderSocialCard(content) { + return new Resvg(socialCardSvg(content), { font: FONT }).render().asPng(); +} diff --git a/tests/seo.test.ts b/tests/seo.test.ts index cfb3ca4..fd54923 100644 --- a/tests/seo.test.ts +++ b/tests/seo.test.ts @@ -4,6 +4,14 @@ import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { beforeAll, describe, expect, it } from "vitest"; +import { + CARD_HEIGHT, + CARD_WIDTH, + MAX_LINES, + renderSocialCard, + socialCardSvg, + wrapText, +} from "../social-card.mjs"; const ROOT_DIR = resolve(__dirname, ".."); const DIST_DIR = join(ROOT_DIR, "dist"); @@ -13,11 +21,21 @@ const SITE_DESCRIPTION = // Pages that use the site description: the home page by design, and pages with // no prose paragraph to describe them (the build logs these). const SITE_DESCRIPTION_PAGES = ["glossary", "index"]; +const SOCIAL_CARD_URL = `${SITE_URL}social-card.png`; const execFileAsync = promisify(execFile); type Page = { slug: string; title: string; html: string }; type JsonLdNode = Record & { "@type": string }; +// Width and height from a PNG's IHDR chunk, after checking the signature. +function pngSize(png: Buffer): { width: number; height: number } { + expect(png.subarray(0, 8)).toEqual( + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + ); + expect(png.toString("latin1", 12, 16)).toBe("IHDR"); + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + function canonicalFor(slug: string): string { return slug === "index" ? SITE_URL : `${SITE_URL}${slug}/`; } @@ -200,12 +218,34 @@ describe("technical SEO", () => { expect(metaContents(html, "property", "og:site_name"), slug).toEqual([ "Refract", ]); + expect(metaContents(html, "property", "og:image"), slug).toEqual([ + SOCIAL_CARD_URL, + ]); + expect(metaContents(html, "property", "og:image:width"), slug).toEqual([ + String(CARD_WIDTH), + ]); + expect(metaContents(html, "property", "og:image:height"), slug).toEqual([ + String(CARD_HEIGHT), + ]); + expect(metaContents(html, "property", "og:image:alt"), slug).toEqual([ + `Refract. ${SITE_DESCRIPTION}`, + ]); expect(metaContents(html, "name", "twitter:card"), slug).toEqual([ - "summary", + "summary_large_image", ]); } }); + it("publishes the 1200×630 card that og:image points to", async () => { + const png = await readFile( + join(DIST_DIR, SOCIAL_CARD_URL.slice(SITE_URL.length)), + ); + expect(pngSize(png)).toEqual({ width: 1200, height: 630 }); + // A flat rectangle compresses to a few kilobytes; rendered text does not. + expect(png.length).toBeGreaterThan(20_000); + expect(png.length).toBeLessThan(500_000); + }); + it("escapes titles in attributes and JSON-LD", () => { const compare = pages.find((page) => page.slug === "compare"); expect(compare?.title).toContain("'"); @@ -279,3 +319,46 @@ describe("technical SEO", () => { } }); }); + +describe("social card", () => { + const description = SITE_DESCRIPTION; + + it("wraps the description into lines that each fit the card", () => { + const lines = wrapText(description); + expect(lines.length).toBeGreaterThan(1); + expect(lines.length).toBeLessThanOrEqual(MAX_LINES); + expect(lines.join(" ")).toBe(description); + for (const line of lines) { + expect(wrapText(line), line).toEqual([line]); + } + }); + + it("refuses a description too long to fit, instead of clipping it", () => { + const long = Array(MAX_LINES + 2) + .fill(description) + .join(" "); + expect(() => + socialCardSvg({ name: "Refract", description: long, address: "x" }), + ).toThrow(/fits \d+ lines/); + }); + + it("escapes text for SVG", () => { + const svg = socialCardSvg({ + name: "A & B", + description: 'Uses & "quotes".', + address: "example.org/?a=1&b=2", + }); + expect(svg).toContain(">A & B"); + expect(svg).toContain("Uses <tags> & "quotes"."); + expect(svg).toContain("example.org/?a=1&b=2"); + }); + + it("renders a 1200×630 PNG", () => { + const png = renderSocialCard({ + name: "Refract", + description, + address: "refract-org.github.io/refract-docs", + }); + expect(pngSize(Buffer.from(png))).toEqual({ width: 1200, height: 630 }); + }); +}); From dc80b6ec83ff8054dbb9ca894cb173d7c8a600a0 Mon Sep 17 00:00:00 2001 From: Kanav Jain Date: Thu, 8 Oct 2026 16:31:56 -0500 Subject: [PATCH 3/5] ci: run lint and tests on pull requests CI only ran the build, so the link, SEO and docs tests ran only where a contributor ran them by hand. It now runs `npm run lint` and `npm test` before the build, which makes the redirect check a merge gate. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f59168b..3aa6654 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,4 +16,6 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run lint + - run: npm test - run: npm run build From 16ec089a9dd63e38cc9b6421601ab634eeae0a19 Mon Sep 17 00:00:00 2001 From: Kanav Jain Date: Thu, 8 Oct 2026 16:52:07 -0500 Subject: [PATCH 4/5] fix(deps): record every platform's native binaries in package-lock.json The lockfile listed only the darwin-arm64 builds of @biomejs/biome, rolldown (Vitest's bundler) and lightningcss. `npm ci` on Linux therefore installed no Biome or Rolldown binary, and `npm run lint` and `npm test` failed there. CI passed only because it ran nothing but the build. The entries for those three packages were re-resolved with `npm install --package-lock-only`. Biome (2.5.15) and Rolldown (1.0.2) keep their versions and gain their other platform builds. lightningcss moves from 1.32.0 to 1.33.0, within Vite's ^1.32.0 range. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 684 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 671 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 893bdbb..cc7c7df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ }, "node_modules/@biomejs/biome": { "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz", + "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -45,6 +47,8 @@ }, "node_modules/@biomejs/cli-darwin-arm64": { "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz", + "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", "cpu": [ "arm64" ], @@ -58,6 +62,159 @@ "node": ">=14.21.3" } }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz", + "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz", + "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz", + "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz", + "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz", + "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz", + "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz", + "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@expo-google-fonts/roboto": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@expo-google-fonts/roboto/-/roboto-0.4.3.tgz", @@ -70,6 +227,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, "node_modules/@oxc-project/types": { "version": "0.132.0", "dev": true, @@ -306,8 +485,27 @@ "node": ">= 10" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-darwin-arm64": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -321,8 +519,233 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -331,6 +754,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "dev": true, @@ -542,7 +976,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -556,21 +992,44 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -588,6 +1047,195 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lint-staged": { "version": "17.6.0", "dev": true, @@ -705,6 +1353,8 @@ }, "node_modules/rolldown": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { @@ -813,6 +1463,14 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/vite": { "version": "8.0.14", "dev": true, From 09183acee53d4a13ae3ecac1ba748d301fb697c0 Mon Sep 17 00:00:00 2001 From: Kanav Jain Date: Thu, 8 Oct 2026 16:52:08 -0500 Subject: [PATCH 5/5] docs: retitle the MCP, Wikipedia tutorial and compare pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Titles come from each page's H1, so these H1s now use the words people search for: - mcp: "MCP — Model Context Protocol" becomes "Wikipedia & MediaWiki revision-history MCP server". The server's tools analyze any MediaWiki page's revisions. - tutorials/wikipedia-history: "Tutorial: Track changes on a Wikipedia page" becomes "Track changes to a Wikipedia article over time". - compare: "Refract vs. Wikipedia's page history" becomes "Wikipedia history tools compared". The intro names the tools the page compares and the question each answers. Each claim about another tool was checked against that tool's documentation, and the page now links it: - page history: the history page links WikiBlame for finding text; reverts are tagged; each revision has a permanent link and an API SHA-1; the watchlist can send email. The page had said "manual", "screenshots are the only record" and "no" for these; - XTools and Who Wrote That? rows added; - WikiBlame split from WhoColor: it searches revisions for the one that added or removed a text, not "visual diff highlighting"; - WikiWho is token-level provenance, including deletions and reinsertions; - Wikimedia Enterprise sells access to a service, not a licence, and has free access. The element is now escaped like the other head values, since the MCP title contains "&". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- build.mjs | 2 +- docs/compare.md | 43 +++++++++++++++++++---------- docs/mcp.md | 2 +- docs/tutorials/wikipedia-history.md | 2 +- tests/seo.test.ts | 19 +++++++++---- 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/build.mjs b/build.mjs index 984542c..63567bb 100644 --- a/build.mjs +++ b/build.mjs @@ -284,7 +284,7 @@ function wrapHTML(title, description, content, currentSlug, headings = []) { <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>${documentTitle(title)} + ${escapeMarkup(documentTitle(title))} diff --git a/docs/compare.md b/docs/compare.md index 7729a32..77e028f 100644 --- a/docs/compare.md +++ b/docs/compare.md @@ -1,34 +1,49 @@ -# Refract vs. Wikipedia's page history +# Wikipedia history tools compared -If you're evaluating whether Refract is worth adopting, this page compares what you -get from Wikipedia's built-in tools vs. what Refract adds. +This page compares tools for reading a Wikipedia article's history: the built-in page history, XTools, WikiBlame, Who Wrote That? and Refract. + +Each answers a different question: + +- the page history lists every edit with its editor, timestamp, edit summary and diff; +- XTools reports statistics about a page's edits and each editor's share of the current text; +- WikiBlame finds the revision in which a given text was inserted or removed; +- Who Wrote That? highlights who wrote each part of the current text; +- Refract reports each change between revisions as a typed event (sentences, citations, templates, reverts, sections), without editor identity. ## Wikipedia's page history -| Capability | Wikipedia UI | Refract | +Sources: [Help:Page history](https://en.wikipedia.org/wiki/Help:Page_history), +[Manual:Reverts](https://www.mediawiki.org/wiki/Manual:Reverts), +[API:Revisions](https://www.mediawiki.org/wiki/API:Revisions), +[Help:Watchlist](https://en.wikipedia.org/wiki/Help:Watchlist). + +| Capability | Page history | Refract | |---|---|---| | **View a single revision diff** | Yes — click "prev" on any revision | Yes — every event carries `before`/`after` snapshots | | **See who edited what** | Yes — username + timestamp per revision | No — Refract observes document change, not editor identity | -| **Find when a sentence first appeared** | Manual — search each revision sequentially | `refract claim "Page" --text "sentence"` → exact revision + timestamp | +| **Find when a sentence first appeared** | Not built in — on English Wikipedia, the history page's "Find addition/removal" link opens WikiBlame | `refract claim "Page" --text "sentence"` → exact revision + timestamp | | **Track a sentence across its entire lifecycle** | Manual — follow the page history | Automatic — first seen, modified, removed, reintroduced, all timestamped | | **Detect citation swapping** | Manual — compare each diff's reference section | `citation_replaced` event — `before`/`after` show the old and new source | -| **Detect edit wars** | Manual — look for back-and-forth in history | `revert_detected` + `edit_cluster_detected` — automatic structural detection | +| **Detect edit wars** | Partly — undos, rollbacks and manual reverts are tagged, and so are the edits they revert; spotting back-and-forth is manual | `revert_detected` + `edit_cluster_detected` — automatic structural detection | | **Correlate article edits with talk page discussion** | Manual — check Talk tab separately | `talk_page_correlated` — Refract checks 7 days before / 3 days after each edit | | **Compare the same topic across language editions** | Manual — open each wiki separately | `refract diff` — cross-wiki comparison with z-score outlier detection | | **Query with SQL** | No | DuckDB: `SELECT "eventType", count(*) FROM 'events.jsonl' GROUP BY 1` | -| **Hash an export so others can check it** | No — screenshots are the only record | `refract export --manifest` → Merkle root over the event hashes; the same version over the same revision range reproduces it | -| **Automated monitoring** | No — you check manually | `refract cron` + `refract watch` → Slack, email, webhook alerts | +| **Hash an export so others can check it** | Per revision — each revision has a permanent link, and the API returns a SHA-1 of its content | `refract export --manifest` → Merkle root over the event hashes; the same version over the same revision range reproduces it | +| **Automated monitoring** | Watchlist, with optional email when any watched page changes | `refract cron` + `refract watch` → Slack, email, webhook alerts | | **AI agent integration** | No | `refract mcp` → Claude Code, Cursor, VS Code can call Refract tools directly | ## Refract vs. other tools | Tool | What it does | Refract's difference | |---|---|---| -| **Wikipedia API** | Raw revision data | Refract adds deterministic analysis, event typing, provenance metadata | -| **WikiWho** | Editor-level authorship attribution | Refract tracks claim lifecycle, not editor attribution. Different question. | -| **WhoColor / WikiBlame** | Visual diff highlighting | Refract structures the data for querying, not just viewing | -| **Wikimedia Enterprise** | Bulk API access, commercial licensing | Refract is open-source, deterministic, and runs locally | -| **Internet Archive** | Historical snapshots | Refract produces structured, queryable event streams, not page captures | +| **[XTools](https://www.mediawiki.org/wiki/XTools)** | [Page History](https://www.mediawiki.org/wiki/XTools/Page_History): statistics about a page's edits, such as top editors and edits per year and month. [Authorship](https://www.mediawiki.org/wiki/XTools/Authorship): each editor's share of the current text, by character count. [Blame](https://www.mediawiki.org/wiki/XTools/Blame): the edits that added a given text. Authorship and Blame use WikiWho | Refract reports each change between revisions as a typed event and does not attribute text to editors | +| **[WikiBlame](https://en.wikipedia.org/wiki/User:Flominator/WikiBlame)** | Searches a page's revisions, by binary or linear search, for the one in which a given text was inserted or removed. Works on MediaWiki wikis | `refract claim` follows one sentence through every revision: first seen, modified, removed, reintroduced | +| **[Who Wrote That?](https://www.mediawiki.org/wiki/Who_Wrote_That%3F)** | Browser extension for Chrome and Firefox that highlights who wrote each part of an article's current text, using WikiWho | Refract does not identify editors; it reports what changed at each revision | +| **[WikiWho](https://www.mediawiki.org/wiki/WikiWho)** | Token-level provenance for about 70 Wikipedia editions: who added, removed or reinserted each token, and in which revision | Refract emits events for sentences, citations, templates, reverts and sections rather than tokens, and does not attribute editors | +| **[WhoColor](https://github.com/wikiwho/WhoColor)** | Userscript that colors article text by author, with conflict and age views, built on WikiWho | Refract structures the data for querying, not just viewing | +| **[Wikimedia Enterprise](https://meta.wikimedia.org/wiki/Wikimedia_Enterprise/FAQ)** | APIs that deliver Wikimedia project content: daily project snapshots, single articles on demand, and a real-time stream of updates. Free access, and paid plans with service-level agreements | Refract analyzes a page's past revisions, is open-source, and runs locally | +| **[MediaWiki API](https://www.mediawiki.org/wiki/API:Revisions)** | Raw revision data: metadata, content and a SHA-1 of each revision | Refract adds deterministic analysis, event typing, provenance metadata | +| **[Internet Archive](https://help.archive.org/help/wayback-machine-general-information/)** | Archived captures of web pages from its crawls | Refract produces structured, queryable event streams, not page captures | | **Custom scrapers** | Ad-hoc revision analysis | Refract has 26 event types, deterministic hashing, and a published SDK | ## What Refract deliberately doesn't do @@ -71,4 +86,4 @@ Refract's model evaluation capability — temporal leakage detection, provenance - You need to see **who** made an edit - You're browsing page history casually -Refract doesn't replace Wikipedia's UI. It adds capabilities that the UI can't provide — deterministic reproducibility, SQL queryability, hashed exports, and automated monitoring. +Refract doesn't replace the page history. It adds typed change events, SQL queries over them, exports that others can reproduce from the same revisions, and scheduled re-observation with Slack, email or webhook notifications. diff --git a/docs/mcp.md b/docs/mcp.md index e910f70..847016b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,4 +1,4 @@ -# MCP — Model Context Protocol +# Wikipedia & MediaWiki revision-history MCP server `refract mcp` starts a JSON-RPC server over stdio that exposes the Refract engine to AI agents via the Model Context Protocol. (`wikihistory mcp` also works.) diff --git a/docs/tutorials/wikipedia-history.md b/docs/tutorials/wikipedia-history.md index 03efdad..5c49642 100644 --- a/docs/tutorials/wikipedia-history.md +++ b/docs/tutorials/wikipedia-history.md @@ -1,4 +1,4 @@ -# Tutorial: Track changes on a Wikipedia page +# Track changes to a Wikipedia article over time ## Goal diff --git a/tests/seo.test.ts b/tests/seo.test.ts index fd54923..f2d627f 100644 --- a/tests/seo.test.ts +++ b/tests/seo.test.ts @@ -132,7 +132,8 @@ describe("technical SEO", () => { const documentTitle = title.startsWith("Refract") ? title : `${title} — Refract`; - expect(html, slug).toContain(`${documentTitle}`); + const titleElement = html.match(/([^<]*)<\/title>/)?.[1] ?? ""; + expect(decodeEntities(titleElement), slug).toBe(documentTitle); } const crossWiki = pages.find((page) => page.slug.endsWith("cross-wiki-diff"), @@ -246,11 +247,19 @@ describe("technical SEO", () => { expect(png.length).toBeLessThan(500_000); }); - it("escapes titles in attributes and JSON-LD", () => { - const compare = pages.find((page) => page.slug === "compare"); - expect(compare?.title).toContain("'"); - expect(compare?.html).toContain("Wikipedia's page history"); + it("escapes titles in the title element, attributes and JSON-LD", () => { + const mcp = pages.find((page) => page.slug === "mcp"); + expect(mcp?.title).toContain("&"); + expect(mcp?.html).toContain("<title>Wikipedia & MediaWiki"); + expect(mcp?.html).toContain( + '<meta property="og:title" content="Wikipedia & MediaWiki', + ); for (const { slug, html } of pages) { + const head = html + .slice(0, html.indexOf("</head>")) + .replace(/<script[\s\S]*?<\/script>/g, ""); + // Outside scripts, every "&" in the head starts a character reference. + expect(head, slug).not.toMatch(/&(?![a-z]+;|#\d+;)/); for (const [, json] of html.matchAll( /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g, )) {