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 = ``;
+ 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 ``;
+}
+
+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("Wikipedia & MediaWiki");
+ expect(mcp?.html).toContain(
+ '"))
+ .replace(/