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 diff --git a/build.mjs b/build.mjs index a1dd053..63567bb 100644 --- a/build.mjs +++ b/build.mjs @@ -1,8 +1,10 @@ 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"; 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"); @@ -18,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); @@ -138,7 +144,11 @@ function renderSeoHead(title, description, slug) { - + + + + + `; } @@ -274,7 +284,7 @@ function wrapHTML(title, description, content, currentSlug, headings = []) { - ${documentTitle(title)} + ${escapeMarkup(documentTitle(title))} @@ -624,24 +634,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 = "") { @@ -750,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); @@ -768,7 +789,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/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/package-lock.json b/package-lock.json index cff3dab..cc7c7df 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", @@ -18,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": { @@ -43,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" ], @@ -56,21 +62,620 @@ "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", + "integrity": "sha512-kX3wmpmu06fN1yOwYWMdjYJWNUYW6xUiAGeHbvPzzSEb3A3j23JelQ3tAgwQNKgEKRq6wtMmzixy2r7y9c8dsA==", + "dev": true, + "license": "MIT AND OFL-1.1" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "dev": true, - "license": "MIT" + "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, + "license": "MIT", + "funding": { + "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-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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "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/@oxc-project/types": { - "version": "0.132.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", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-arm64": { + "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" ], @@ -78,7 +683,60 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "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" @@ -86,6 +744,8 @@ }, "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" }, @@ -94,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, @@ -305,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": { @@ -319,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" ], @@ -351,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, @@ -468,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": { @@ -576,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, 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/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); + }); }); diff --git a/tests/seo.test.ts b/tests/seo.test.ts index cfb3ca4..f2d627f 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}/`; } @@ -114,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"), @@ -200,17 +219,47 @@ 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("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("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 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, )) { @@ -279,3 +328,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 <tags> & "quotes".', + address: "example.org/?a=1&b=2", + }); + expect(svg).toContain(">A & B</text>"); + 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 }); + }); +});