diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..86b174c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,52 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+# Two packages, two jobs. They share a repository and an idea; they share no
+# build.
+jobs:
+ js:
+ name: js (${{ matrix.node }})
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: js
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ['18', '20', '22']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: npm
+ cache-dependency-path: js/package-lock.json
+ - run: npm ci
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run build
+
+ python:
+ name: python (${{ matrix.python }})
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: python
+ strategy:
+ fail-fast: false
+ matrix:
+ python: ['3.9', '3.10', '3.11', '3.12', '3.13']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python }}
+ # Installing the package rather than putting src/ on the path is what
+ # proves the packaging works, not only the source tree.
+ - run: python -m pip install --upgrade pip build
+ - run: python -m pip install .
+ - run: python -m unittest discover -s tests -v
+ - run: python -m build
diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml
new file mode 100644
index 0000000..bd7e685
--- /dev/null
+++ b/.github/workflows/release-npm.yml
@@ -0,0 +1,39 @@
+name: Release (npm)
+
+# Manual only. A release is a decision, not a side effect of a merge.
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'npm dist-tag to publish under'
+ required: false
+ default: 'latest'
+
+permissions:
+ contents: read
+ id-token: write # npm provenance
+
+jobs:
+ publish:
+ name: Publish @kingdom-community/github-docs
+ runs-on: ubuntu-latest
+ environment: npm
+ defaults:
+ run:
+ working-directory: js
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ registry-url: 'https://registry.npmjs.org'
+ cache: npm
+ cache-dependency-path: js/package-lock.json
+ - run: npm ci
+ # Never publish something that was not just proven to build and pass.
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run build
+ - run: npm publish --provenance --access public --tag "${{ inputs.tag }}"
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml
new file mode 100644
index 0000000..74951fd
--- /dev/null
+++ b/.github/workflows/release-pypi.yml
@@ -0,0 +1,33 @@
+name: Release (PyPI)
+
+# Manual only. A release is a decision, not a side effect of a merge.
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write # PyPI trusted publishing
+
+jobs:
+ publish:
+ name: Publish github-docs
+ runs-on: ubuntu-latest
+ environment: pypi
+ defaults:
+ run:
+ working-directory: python
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - run: python -m pip install --upgrade pip build
+ - run: python -m pip install .
+ # Never publish something that was not just proven to pass.
+ - run: python -m unittest discover -s tests -v
+ - run: python -m build
+ # Trusted publishing: configure this repo + workflow as a publisher on
+ # PyPI, and no long-lived API token has to exist anywhere.
+ - uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: python/dist
diff --git a/README.md b/README.md
index cf2fdb6..4cab66d 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,129 @@
# github-docs
-Run your community's documentation out of a GitHub repository. This repo will
-ship two packages that share one idea: the docs live as markdown in a git repo,
-the website reads them, and edits land as pull requests. `js/` will hold
-`@kingdom-community/github-docs`, a TypeScript reader that fetches markdown for
-rendering and never lets an upstream failure become a 5xx or leak a token.
-`python/` will hold `github-docs`, a stdlib-only writer that commits an edit to
-a per-file branch and opens (or reuses) a pull request for it.
+**Run your community's documentation out of a GitHub repository.**
+
+Your rules page, your onboarding guide, your staff handbook: write them as
+markdown, keep them in a git repo, and let the repo be the source of truth. Your
+website renders them. Edits from your web app arrive as pull requests, so
+someone reviews a change to the rules before it is the rules.
+
+That is one idea with two halves, and the two halves were built in different
+languages — so this repo ships two packages.
+
+| | Package | Language | Does |
+|---|---|---|---|
+| **Read** | [`@kingdom-community/github-docs`](./js) | TypeScript | Fetches markdown from the repo for rendering |
+| **Write** | [`github-docs`](./python) | Python (stdlib only) | Lands edits as pull requests |
+
+They are independent. Use one, use the other, use both. They do not talk to each
+other; they talk to the same repository.
+
+## The shape of it
+
+```
+ acme-guild/handbook (markdown in git)
+ ▲ │
+ │ pull request │ fetch
+ │ ▼
+ github-docs (Python) @kingdom-community/github-docs (TS)
+ your admin/staff app your website
+```
+
+## Read side — `js/`
+
+```bash
+npm install @kingdom-community/github-docs
+```
+
+```ts
+import {createDocsClient} from '@kingdom-community/github-docs';
+
+const docs = createDocsClient({
+ repo: 'acme-guild/handbook',
+ documents: ['handbook/rules.md', 'handbook/getting-started.md']
+});
+
+const result = await docs.fetchMarkdown('rules');
+if (result.status === 'ok') {
+ render(result.markdown);
+} else {
+ renderPanel(`Read it on GitHub: ${docs.webUrl('rules')}`);
+}
+```
+
+Two rules run through all of it, and they are the reason it is worth installing
+rather than writing four lines of `fetch`:
+
+1. **Failure is a value, not an exception.** Every function returns a
+ discriminated result. GitHub having a bad minute produces a readable panel
+ and an HTTP 200, never a 5xx. A page that has to remember to catch is a page
+ that will one day forget.
+2. **Nothing upstream is ever quoted back.** No response body, no header, no
+ request URL, and above all no token appears in a returned value or in an
+ error. There is no path by which a GitHub error page reaches your HTML.
+
+It also ships the URL-scheme allowlist that keeps `[click me](javascript:…)` in
+a community-authored document from becoming script execution in a reader's
+browser — including the spellings that survive a naive
+`startsWith('javascript:')`.
+
+[Full documentation →](./js/README.md)
+
+## Write side — `python/`
+
+```bash
+pip install github-docs
+```
+
+```python
+from github_docs import GitHubDocsClient, GitHubDocsConfig
+
+docs = GitHubDocsClient(GitHubDocsConfig(
+ repo="acme-guild/handbook",
+ token=os.environ["DOCS_GITHUB_TOKEN"],
+ allowed_roots=("handbook", "policies"),
+))
+
+result = docs.save_file("handbook/rules.md", new_text, author="mod99")
+print(result.pr_url) # https://github.com/acme-guild/handbook/pull/42
+```
+
+Never a direct push. A save commits to a per-file branch and opens a pull
+request — or finds the open one from the last save and adds to it, so repeated
+edits to the same page update one PR instead of piling up duplicates. The pull
+request *is* the review mechanism, which is why there is no diff or version UI
+to build.
+
+Standard library `urllib` only. No `requests`, no dependency tree.
+
+[Full documentation →](./python/README.md)
+
+## Why the split
+
+The read side runs in a website's render layer, where the language is
+TypeScript and the constraint is that an outage must not become an error page.
+The write side runs in an admin app, where the language is Python and the
+constraint is that nothing bypasses review. Different jobs, different failure
+modes, different code. Sharing a repository keeps the two descriptions of "how
+this community's docs work" from drifting apart.
+
+## Development
+
+```bash
+# read side
+cd js && npm install && npm test
+
+# write side
+cd python && python3 -m unittest discover -s tests -v
+```
+
+CI runs both on every push and pull request.
+
+## License
+
+MIT.
+
+## Origins
+
+Extracted from the website and infrastructure stack behind a Minecraft
+community server, generalised and released under MIT.
diff --git a/js/LICENSE b/js/LICENSE
new file mode 100644
index 0000000..f8d146f
--- /dev/null
+++ b/js/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Daniel McCoy Stephenson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/js/README.md b/js/README.md
new file mode 100644
index 0000000..ba9bf31
--- /dev/null
+++ b/js/README.md
@@ -0,0 +1,200 @@
+# @kingdom-community/github-docs
+
+Read your community's documentation out of a GitHub repository.
+
+Fetches markdown from a repo so your site can render it, under two rules:
+**failure is a value, not an exception**, and **nothing upstream is ever quoted
+back**. Ships the URL-scheme allowlist that keeps a community-authored markdown
+link from becoming script execution in a reader's browser.
+
+This is the read half of [`kingdom-community/github-docs`](https://github.com/kingdom-community/github-docs).
+The write half — landing edits as pull requests — is the Python package
+[`github-docs`](https://github.com/kingdom-community/github-docs/tree/main/python).
+
+## Install
+
+```bash
+npm install @kingdom-community/github-docs
+```
+
+Node 18 or newer (it uses the global `fetch`). ESM. Types included.
+
+## Usage
+
+```ts
+import {createDocsClient} from '@kingdom-community/github-docs';
+
+const docs = createDocsClient({
+ repo: 'acme-guild/handbook',
+ // Deny-by-default: only these are ever fetched, and anything else 404s
+ // without asking GitHub at all.
+ documents: [
+ {path: 'handbook/getting-started.md', title: 'Getting started', summary: 'Your first hour.'},
+ 'handbook/rules.md',
+ 'handbook/commands.md'
+ ]
+});
+
+// An index page.
+for (const doc of docs.documents) {
+ console.log(doc.slug, doc.title, doc.summary);
+}
+
+// A document page, at /docs/[slug].
+const result = await docs.fetchMarkdown(slug);
+switch (result.status) {
+ case 'ok':
+ return {props: {markdown: result.markdown}};
+ case 'not-listed':
+ return {notFound: true};
+ case 'not-configured':
+ // A deployment state, not an outage. Say so.
+ return {props: {panel: 'Documentation is not configured on this deployment.'}};
+ case 'unavailable':
+ return {props: {panel: `Read it on GitHub: ${docs.webUrl(slug)}`}};
+}
+```
+
+Note what does *not* appear: a `try`/`catch`. `fetchMarkdown` never rejects. It
+returns a value for every way a fetch can go wrong — a 404, a rate limit, an
+upstream 500, a timeout, a DNS failure, an oversized body — so a page cannot
+forget to handle one and turn someone else's bad minute into your 5xx.
+
+### A private repository
+
+```ts
+const docs = createDocsClient({
+ repo: 'acme-guild/internal-handbook',
+ token: process.env.DOCS_GITHUB_TOKEN, // server-side secret, see below
+ documents: process.env.DOCS_PUBLIC_FILES // "a/b.md, a/c.md"
+});
+```
+
+With a token, the client reads through the GitHub Contents API (which accepts
+one) rather than `raw.githubusercontent.com` (which does not). Without a token
+it reads the raw host and sends no credentials at all — because sending
+credentials to a host that does not need them is how credentials end up
+somewhere they should not be.
+
+### Rendering safely
+
+Every URL a rendered document is about to emit should pass through the
+allowlist first, in the renderer's component override or link callback — not
+against the markdown source, which misses reference-style links entirely.
+
+```tsx
+import Markdown from 'markdown-to-jsx';
+import {isExternalUrl, safeImageUrl, safeLinkUrl} from '@kingdom-community/github-docs';
+
+const Link = ({href, children}: {href?: string; children?: React.ReactNode}) => {
+ const safe = safeLinkUrl(href);
+ if (safe === null) {
+ return <>{children}>; // plain text, not a link to nowhere
+ }
+ const external = isExternalUrl(safe);
+ return (
+
+ {children}
+
+ );
+};
+
+const Image = ({src, alt}: {src?: string; alt?: string}) => {
+ const safe = safeImageUrl(src);
+ return safe === null ? null :
;
+};
+
+{markdown};
+```
+
+### Links written for GitHub
+
+`[Rules](rules.md)` works when the document is read inside the repository. On
+your site the browser would resolve it against your origin and 404. Point it
+back at the source:
+
+```ts
+docs.resolveLink('rules.md', 'handbook/getting-started.md');
+// https://github.com/acme-guild/handbook/blob/HEAD/handbook/rules.md
+```
+
+## Configuration
+
+| Option | Default | What it does |
+|---|---|---|
+| `repo` | *(required)* | `owner/repo`, or a GitHub URL. Validated when the client is built — the only place this package throws. |
+| `ref` | `'HEAD'` | The git ref to read. `HEAD` resolves to the default branch whatever it is called. |
+| `token` | none | A read token, for a private repository. Omit for a public one. |
+| `transport` | `'api'` with a token, `'raw'` without | Contents API vs `raw.githubusercontent.com`. |
+| `documents` | *(unset)* | The catalogue. A list, or a comma-separated string. **Unset means the whole repository is fetchable; an empty list means nothing is.** |
+| `timeoutMs` | `5000` | A page renders its panel rather than making a visitor wait on someone else's outage. |
+| `maxDocumentBytes` | `1048576` | Anything larger is not the document that was asked for. |
+| `apiBase` | `https://api.github.com` | For GitHub Enterprise. |
+| `fetchImpl` | global `fetch` | Injectable, for tests. |
+
+### Environment variables
+
+This package reads none itself — a library that reads `process.env` is a library
+you cannot test twice with different settings. Read them in your app and pass
+the values in. Whatever you name yours, the token variable **must not** carry a
+client-bundle prefix (`NEXT_PUBLIC_`, `VITE_`, …), or your bundler will inline
+the credential into the browser bundle.
+
+```ts
+createDocsClient({
+ repo: process.env.DOCS_REPO!,
+ token: process.env.DOCS_GITHUB_TOKEN,
+ documents: process.env.DOCS_PUBLIC_FILES
+});
+```
+
+## The result type
+
+```ts
+type MarkdownFetch =
+ | {status: 'ok'; markdown: string}
+ | {status: 'unavailable'} // reached GitHub, did not get the document
+ | {status: 'not-configured'} // a private repo with no token: a deployment state
+ | {status: 'not-listed'}; // not in the catalogue; nothing was requested
+```
+
+`unavailable` deliberately does not say *why*. A page treats every upstream
+failure the same way, and distinguishing them would mean deciding what to say
+about an upstream status — which is the beginning of quoting upstream back.
+
+## API
+
+Everything is exported from the package root.
+
+**Client** — `createDocsClient`, `fetchRawMarkdown`, `fetchApiMarkdown`,
+`DEFAULT_TIMEOUT_MS`, `DEFAULT_MAX_DOCUMENT_BYTES`.
+
+**Catalogue** — `parseCatalogue`, `entryForSlug`, `entryForPath`,
+`isSafeDocPath`, `slugForPath`, `titleForSlug`.
+
+**Repository** — `parseRepoSlug`, `requireRepoSlug`, `repoWebUrl`,
+`releasesUrl`.
+
+**URLs** — `rawUrl`, `blobUrl`, `contentsApiUrl`, `resolveDocLink`,
+`resolveRepoPath`, `isSelfContainedTarget`, `encodeDocPath`, `DEFAULT_REF`.
+
+**Markdown URL safety** — `safeLinkUrl`, `safeImageUrl`, `isExternalUrl`,
+`ALLOWED_LINK_SCHEMES`, `ALLOWED_IMAGE_SCHEMES`.
+
+## Development
+
+```bash
+npm install
+npm test # vitest
+npm run typecheck
+npm run build
+```
+
+## License
+
+MIT.
+
+## Origins
+
+Extracted from the website and infrastructure stack behind a Minecraft
+community server, generalised and released under MIT.
diff --git a/js/package-lock.json b/js/package-lock.json
new file mode 100644
index 0000000..b3fd13d
--- /dev/null
+++ b/js/package-lock.json
@@ -0,0 +1,1890 @@
+{
+ "name": "@kingdom-community/github-docs",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@kingdom-community/github-docs",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "typescript": "^5.4.5",
+ "vitest": "^1.6.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.12",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
+ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
+ "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "chai": "^4.3.10"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz",
+ "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "1.6.1",
+ "p-limit": "^5.0.0",
+ "pathe": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz",
+ "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz",
+ "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^2.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz",
+ "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "diff-sequences": "^29.6.3",
+ "estree-walker": "^3.0.3",
+ "loupe": "^2.3.7",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+ "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
+ "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.3",
+ "pkg-types": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/mlly/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+ "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/pkg-types/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
+ "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinypool": {
+ "version": "0.8.4",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
+ "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
+ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz",
+ "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.4",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
+ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "1.6.1",
+ "@vitest/runner": "1.6.1",
+ "@vitest/snapshot": "1.6.1",
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "acorn-walk": "^8.3.2",
+ "chai": "^4.3.10",
+ "debug": "^4.3.4",
+ "execa": "^8.0.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "tinybench": "^2.5.1",
+ "tinypool": "^0.8.3",
+ "vite": "^5.0.0",
+ "vite-node": "1.6.1",
+ "why-is-node-running": "^2.2.2"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "1.6.1",
+ "@vitest/ui": "1.6.1",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/js/package.json b/js/package.json
new file mode 100644
index 0000000..c472cd6
--- /dev/null
+++ b/js/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@kingdom-community/github-docs",
+ "version": "0.1.0",
+ "description": "Read a community's documentation out of a GitHub repository: fetch markdown for rendering, with every failure returned as a value and nothing upstream ever quoted back.",
+ "license": "MIT",
+ "author": "Daniel McCoy Stephenson",
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "sideEffects": false,
+ "engines": {
+ "node": ">=18"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "vitest run",
+ "prepublishOnly": "npm run build"
+ },
+ "devDependencies": {
+ "typescript": "^5.4.5",
+ "vitest": "^1.6.1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/kingdom-community/github-docs.git",
+ "directory": "js"
+ },
+ "bugs": {
+ "url": "https://github.com/kingdom-community/github-docs/issues"
+ },
+ "homepage": "https://github.com/kingdom-community/github-docs/tree/main/js#readme",
+ "keywords": [
+ "github",
+ "markdown",
+ "documentation",
+ "docs-as-code",
+ "community",
+ "content",
+ "typescript"
+ ],
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/js/src/catalogue.ts b/js/src/catalogue.ts
new file mode 100644
index 0000000..e7661cc
--- /dev/null
+++ b/js/src/catalogue.ts
@@ -0,0 +1,130 @@
+// The catalogue: which documents a site is willing to publish, what URL each
+// one lives at, and nothing else.
+//
+// Pure and dependency-free — nothing here reads the environment or touches the
+// network. The raw configuration value arrives as an argument, read once by
+// whatever constructs the client. Keeping it pure is what lets these rules be
+// unit-tested exhaustively, which matters more here than anywhere else in the
+// package: a catalogue is a security boundary as much as a convenience.
+//
+// It is a deny-by-default gate. A path not named in the catalogue is never
+// fetched, never rendered, and never appears in an index or a sitemap; the
+// check happens before the fetch, not after, so a site cannot be made to pull
+// an unlisted file into memory at all.
+//
+// A catalogue is also cheaper than a directory listing: `/docs/` answers
+// 404 for anything unknown without asking GitHub, which is one fewer upstream
+// call and one fewer way to fail. That is worth doing for a public repository
+// too, not only for a private one.
+
+export interface DocEntry {
+ /** The URL segment: `/docs/`. */
+ slug: string;
+ /**
+ * The path in the repository, exactly as the catalogue named it. This is
+ * the only string ever sent to GitHub.
+ */
+ path: string;
+ /** A display title. Derived from the filename unless one was given. */
+ title: string;
+ /** One line for an index page or a meta description. Optional. */
+ summary?: string;
+}
+
+/**
+ * A catalogue entry as a caller may write it: a bare path, or a path with any
+ * of the derived fields overridden.
+ */
+export type DocEntryInput = string | ({path: string} & Partial>);
+
+// A path this package is willing to send to GitHub. Deliberately narrow: lower-
+// and upper-case letters, digits, dot, dash, underscore and the separating
+// slash. Anything else — a `..` segment, a leading slash, a backslash, a
+// scheme, a query string, whitespace — is not a path in a repository and is
+// dropped rather than escaped, because a catalogue entry that needs escaping is
+// a typo or an attack and neither should reach the network.
+const SAFE_DOC_PATH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
+
+export const isSafeDocPath = (path: string): boolean =>
+ SAFE_DOC_PATH.test(path) &&
+ !path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..');
+
+/** `handbook/the-rules.md` -> `the-rules`. */
+export const slugForPath = (path: string): string => {
+ const base = path.split('/').pop() ?? path;
+ return base
+ .replace(/\.mdx?$/i, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+};
+
+/**
+ * `the-rules` -> `The Rules`. A filename is not a title, but it is an honest
+ * approximation of one, and it is what the author typed.
+ *
+ * The alternative — fetching every catalogued file to read its first heading —
+ * turns one index request into N upstream calls and N ways to fail. The
+ * document's own heading appears once the page opens, which is soon enough.
+ */
+export const titleForSlug = (slug: string): string =>
+ slug
+ .split('-')
+ .filter((word) => word !== '')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+
+/**
+ * Build a catalogue.
+ *
+ * Accepts an array of entries, or a comma-separated string — the shape an
+ * environment variable comes in. Order is preserved: it is the maintainer's
+ * reading order, and there is no better one to invent.
+ *
+ * Unset, empty, or entirely unusable input yields an empty catalogue, which is
+ * the "nothing is published" state rather than the "everything is published"
+ * one. Unusable entries are dropped individually rather than throwing, so one
+ * typo in a list of thirty does not take a site down.
+ *
+ * Two entries whose filenames slugify the same would fight over one URL; the
+ * first wins and the second is dropped, because silently serving one document
+ * at another's URL is the worse failure.
+ */
+export const parseCatalogue = (
+ input: readonly DocEntryInput[] | string | undefined | null
+): DocEntry[] => {
+ const candidates: DocEntryInput[] =
+ typeof input === 'string' ? input.split(',') : input ? [...input] : [];
+ const entries: DocEntry[] = [];
+ const seenSlugs = new Set();
+ for (const candidate of candidates) {
+ const given = typeof candidate === 'string' ? {path: candidate} : candidate;
+ const path = (given.path ?? '').trim();
+ if (path === '' || !isSafeDocPath(path)) {
+ continue;
+ }
+ const slug = (given.slug ?? slugForPath(path)).trim();
+ if (slug === '' || seenSlugs.has(slug)) {
+ continue;
+ }
+ seenSlugs.add(slug);
+ const entry: DocEntry = {slug, path, title: given.title ?? titleForSlug(slug)};
+ if (given.summary !== undefined) {
+ entry.summary = given.summary;
+ }
+ entries.push(entry);
+ }
+ return entries;
+};
+
+/**
+ * The catalogue lookup, and the only way a path should reach the fetch layer.
+ * An unknown slug returns null, the page answers 404, and nothing is sent to
+ * GitHub.
+ */
+export const entryForSlug = (entries: readonly DocEntry[], slug: string): DocEntry | null =>
+ entries.find((entry) => entry.slug === slug) ?? null;
+
+/** The same lookup by repository path rather than by slug. */
+export const entryForPath = (entries: readonly DocEntry[], path: string): DocEntry | null =>
+ entries.find((entry) => entry.path === path) ?? null;
diff --git a/js/src/client.ts b/js/src/client.ts
new file mode 100644
index 0000000..74a4053
--- /dev/null
+++ b/js/src/client.ts
@@ -0,0 +1,293 @@
+// The one outbound call: fetching a markdown document out of a GitHub
+// repository so a site can render it.
+//
+// Two failure rules run through everything below, and they are the reason this
+// module is worth having rather than inlining a `fetch`.
+//
+// 1. **Failure is a value, not an exception.** Every function here answers
+// with a discriminated result. GitHub having a bad minute must produce a
+// readable panel and an HTTP 200, never a 5xx — and a page that has to
+// remember to catch is a page that will one day forget.
+// 2. **Nothing upstream is ever quoted back.** No response body, no header, no
+// request URL and above all no token appears in a returned value or in an
+// error. The token travels in an `Authorization` header, so it is never in
+// a URL to begin with; keeping bodies out too means there is no path by
+// which a GitHub error page reaches a site's HTML.
+//
+// This is server-side code. The token must be read from a secret that carries
+// no client-bundle prefix (no `NEXT_PUBLIC_`, no `VITE_`) and handed in here;
+// it must never be reachable from a browser bundle.
+
+import {
+ entryForPath,
+ entryForSlug,
+ parseCatalogue,
+ type DocEntry,
+ type DocEntryInput
+} from './catalogue.js';
+import {requireRepoSlug} from './repo.js';
+import {blobUrl, contentsApiUrl, DEFAULT_REF, rawUrl, resolveDocLink} from './urls.js';
+
+/**
+ * A few seconds. A page renders its "read it on GitHub" panel rather than
+ * making a visitor wait on someone else's outage.
+ */
+export const DEFAULT_TIMEOUT_MS = 5000;
+
+/**
+ * A megabyte. A documentation page is a few kilobytes; anything past this is
+ * not the document that was asked for, and reading it into memory would be a
+ * sink on a response this process does not control.
+ */
+export const DEFAULT_MAX_DOCUMENT_BYTES = 1048576;
+
+export type MarkdownFetch =
+ | {status: 'ok'; markdown: string}
+ // Reached GitHub and did not get the document: a 404, a rate limit, a 500,
+ // a timeout, a DNS failure, an oversized body. A page treats all of these
+ // the same way — show the panel, link to GitHub — so they are not
+ // distinguished here either. Distinguishing them would also mean deciding
+ // what to say about an upstream status, which is the beginning of quoting
+ // upstream back.
+ | {status: 'unavailable'}
+ // Asked for a document from a private repository with no token configured.
+ // Distinct from `unavailable` because it is a deployment state rather than
+ // an outage, and the panel should say so: nobody is served by telling an
+ // operator that GitHub is down when the truth is that they never set the
+ // secret.
+ | {status: 'not-configured'}
+ // The path is not in the catalogue. Also a deployment state rather than an
+ // outage, and no request was made. A site normally turns this into a 404.
+ | {status: 'not-listed'};
+
+export type Transport = 'raw' | 'api';
+
+export interface DocsClientConfig {
+ /** `owner/repo`, or a GitHub URL. Validated when the client is built. */
+ repo: string;
+ /**
+ * The git ref to read. Defaults to `HEAD`, which resolves to the
+ * repository's default branch whatever it is called.
+ */
+ ref?: string;
+ /**
+ * A token with read access, for a private repository. Omit it for a public
+ * one: sending credentials to a host that does not need them is how
+ * credentials end up somewhere they should not be.
+ */
+ token?: string | null;
+ /**
+ * `raw` reads raw.githubusercontent.com; `api` reads the Contents API.
+ * Defaults to `api` when a token is configured and `raw` when one is not,
+ * because raw.githubusercontent.com does not accept a bearer token and the
+ * Contents API is the path that works for a private repository.
+ */
+ transport?: Transport;
+ /**
+ * The documents this site may publish. Provide a list (or the
+ * comma-separated string an environment variable comes in) to get a
+ * deny-by-default catalogue: anything not named is refused before any
+ * request is made.
+ *
+ * Leave it undefined for a repository whose whole contents are publishable,
+ * in which case any syntactically valid path is fetched. Note that an empty
+ * list is NOT the same as undefined — it publishes nothing, which is the
+ * safe reading of "the operator has not filled this in yet".
+ */
+ documents?: readonly DocEntryInput[] | string;
+ /** Defaults to {@link DEFAULT_TIMEOUT_MS}. */
+ timeoutMs?: number;
+ /** Defaults to {@link DEFAULT_MAX_DOCUMENT_BYTES}. */
+ maxDocumentBytes?: number;
+ /** Override the API host. Mostly useful for a GitHub Enterprise install. */
+ apiBase?: string;
+ /** Injectable for tests; defaults to the global `fetch`. */
+ fetchImpl?: typeof fetch;
+}
+
+export interface DocsClient {
+ /** The repository slug, normalised. */
+ readonly repo: string;
+ /** The ref being read. */
+ readonly ref: string;
+ /** The catalogue, in the order it was configured. Empty when unset. */
+ readonly documents: readonly DocEntry[];
+ /** Catalogue lookup by URL segment. Null when unknown. */
+ entryForSlug(slug: string): DocEntry | null;
+ /** Catalogue lookup by repository path. Null when unknown. */
+ entryForPath(path: string): DocEntry | null;
+ /** Fetch a document by catalogue entry, slug, or repository path. */
+ fetchMarkdown(target: DocEntry | string): Promise;
+ /** Where a reader is sent when the fetch fails. */
+ webUrl(target: DocEntry | string): string;
+ /** The plain-text URL this client would fetch. */
+ rawUrl(target: DocEntry | string): string;
+ /** Rewrite a link found inside a fetched document. See `resolveDocLink`. */
+ resolveLink(href: string, fromPath?: string): string;
+}
+
+// One fetch, bounded by a timeout and a size limit, with every failure
+// collapsed to `unavailable`.
+const fetchMarkdown = async (
+ fetchImpl: typeof fetch,
+ url: string,
+ headers: Record,
+ timeoutMs: number,
+ maxBytes: number
+): Promise => {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetchImpl(url, {headers, signal: controller.signal});
+ if (!response.ok) {
+ // The body is not read, not logged and not returned. An upstream
+ // that echoed a credential back — GitHub does not, but an
+ // intermediary might — must not be able to launder it through here.
+ return {status: 'unavailable'};
+ }
+ const text = await response.text();
+ if (text.length > maxBytes) {
+ return {status: 'unavailable'};
+ }
+ return {status: 'ok', markdown: text};
+ } catch {
+ // Deliberately swallowed rather than rethrown or logged with the error
+ // attached: the caller's contract is a value, and an upstream error
+ // string is upstream text this process has no reason to carry around.
+ return {status: 'unavailable'};
+ } finally {
+ clearTimeout(timer);
+ }
+};
+
+/**
+ * A document from a public repository, over raw.githubusercontent.com. No
+ * token is sent, because none is needed.
+ */
+export const fetchRawMarkdown = async (
+ url: string,
+ options: {timeoutMs?: number; maxDocumentBytes?: number; fetchImpl?: typeof fetch} = {}
+): Promise =>
+ fetchMarkdown(
+ options.fetchImpl ?? fetch,
+ url,
+ {Accept: 'text/plain'},
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
+ options.maxDocumentBytes ?? DEFAULT_MAX_DOCUMENT_BYTES
+ );
+
+/**
+ * A document through the GitHub Contents API, which is the path that works for
+ * a private repository.
+ *
+ * `vnd.github.raw` asks the API for the file itself rather than a JSON envelope
+ * with a base64 body — one less encoding to get wrong, and no metadata about a
+ * private repository in the response.
+ */
+export const fetchApiMarkdown = async (
+ url: string,
+ options: {
+ token?: string | null;
+ timeoutMs?: number;
+ maxDocumentBytes?: number;
+ fetchImpl?: typeof fetch;
+ } = {}
+): Promise => {
+ const token = options.token?.trim();
+ const headers: Record = {
+ Accept: 'application/vnd.github.raw',
+ 'X-GitHub-Api-Version': '2022-11-28'
+ };
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ return fetchMarkdown(
+ options.fetchImpl ?? fetch,
+ url,
+ headers,
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
+ options.maxDocumentBytes ?? DEFAULT_MAX_DOCUMENT_BYTES
+ );
+};
+
+/**
+ * Build a reader for one repository.
+ *
+ * Throws only here, and only for configuration that cannot describe a
+ * repository. After construction every failure is a value.
+ */
+export const createDocsClient = (config: DocsClientConfig): DocsClient => {
+ const repo = requireRepoSlug(config.repo);
+ const ref = config.ref?.trim() || DEFAULT_REF;
+ const token = config.token?.trim() || null;
+ const transport: Transport = config.transport ?? (token ? 'api' : 'raw');
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const maxDocumentBytes = config.maxDocumentBytes ?? DEFAULT_MAX_DOCUMENT_BYTES;
+ const apiBase = config.apiBase ?? 'https://api.github.com';
+ const fetchImpl = config.fetchImpl ?? ((...args: Parameters) => fetch(...args));
+ const hasCatalogue = config.documents !== undefined;
+ const documents: readonly DocEntry[] = hasCatalogue ? parseCatalogue(config.documents) : [];
+
+ // Resolve whatever the caller passed to a repository path, or null when the
+ // catalogue refuses it. This is the gate: it runs before any URL is built,
+ // so an unlisted path is never even turned into a request.
+ const pathOf = (target: DocEntry | string): string | null => {
+ if (typeof target !== 'string') {
+ return target.path;
+ }
+ const value = target.trim();
+ if (value === '') {
+ return null;
+ }
+ if (hasCatalogue) {
+ const found = entryForSlug(documents, value) ?? entryForPath(documents, value);
+ return found ? found.path : null;
+ }
+ return parseCatalogue([value])[0]?.path ?? null;
+ };
+
+ const requirePath = (target: DocEntry | string): string => {
+ const path = pathOf(target);
+ if (path === null) {
+ // Configuration, not upstream: quoting the caller's own value back
+ // is safe and is the only way this is debuggable.
+ throw new Error(
+ `github-docs: ${JSON.stringify(typeof target === 'string' ? target : target.path)} is not a published document`
+ );
+ }
+ return path;
+ };
+
+ return {
+ repo,
+ ref,
+ documents,
+ entryForSlug: (slug) => entryForSlug(documents, slug),
+ entryForPath: (path) => entryForPath(documents, path),
+ webUrl: (target) => blobUrl(repo, requirePath(target), ref),
+ rawUrl: (target) => rawUrl(repo, requirePath(target), ref),
+ resolveLink: (href, fromPath) => resolveDocLink(repo, href, {fromPath, ref}),
+ fetchMarkdown: async (target) => {
+ const path = pathOf(target);
+ if (path === null) {
+ return {status: 'not-listed'};
+ }
+ if (transport === 'api') {
+ if (!token) {
+ return {status: 'not-configured'};
+ }
+ return fetchApiMarkdown(contentsApiUrl(repo, path, ref, apiBase), {
+ token,
+ timeoutMs,
+ maxDocumentBytes,
+ fetchImpl
+ });
+ }
+ return fetchRawMarkdown(rawUrl(repo, path, ref), {
+ timeoutMs,
+ maxDocumentBytes,
+ fetchImpl
+ });
+ }
+ };
+};
diff --git a/js/src/index.ts b/js/src/index.ts
new file mode 100644
index 0000000..9442e38
--- /dev/null
+++ b/js/src/index.ts
@@ -0,0 +1,49 @@
+// @kingdom-community/github-docs — the read half.
+//
+// Fetches markdown out of a GitHub repository so a site can render it, under
+// two rules: failure is a value rather than an exception, and nothing upstream
+// is ever quoted back.
+
+export {
+ createDocsClient,
+ fetchApiMarkdown,
+ fetchRawMarkdown,
+ DEFAULT_MAX_DOCUMENT_BYTES,
+ DEFAULT_TIMEOUT_MS,
+ type DocsClient,
+ type DocsClientConfig,
+ type MarkdownFetch,
+ type Transport
+} from './client.js';
+
+export {
+ entryForPath,
+ entryForSlug,
+ isSafeDocPath,
+ parseCatalogue,
+ slugForPath,
+ titleForSlug,
+ type DocEntry,
+ type DocEntryInput
+} from './catalogue.js';
+
+export {parseRepoSlug, releasesUrl, repoWebUrl, requireRepoSlug} from './repo.js';
+
+export {
+ blobUrl,
+ contentsApiUrl,
+ encodeDocPath,
+ isSelfContainedTarget,
+ rawUrl,
+ resolveDocLink,
+ resolveRepoPath,
+ DEFAULT_REF
+} from './urls.js';
+
+export {
+ isExternalUrl,
+ safeImageUrl,
+ safeLinkUrl,
+ ALLOWED_IMAGE_SCHEMES,
+ ALLOWED_LINK_SCHEMES
+} from './markdownUrl.js';
diff --git a/js/src/markdownUrl.ts b/js/src/markdownUrl.ts
new file mode 100644
index 0000000..80f5a7c
--- /dev/null
+++ b/js/src/markdownUrl.ts
@@ -0,0 +1,121 @@
+// The URL-scheme allowlist. Every URL a rendered markdown document is about to
+// emit passes through here first.
+//
+// A document fetched from a git repository is authored content, and on a
+// community site it is often authored by more than one person. These functions
+// are the control that stops a markdown link from becoming script execution in
+// a reader's browser.
+//
+// WHY THIS IS APPLIED TO THE RESOLVED URL AND NOT TO THE MARKDOWN SOURCE
+//
+// Validating the source misses reference-style links entirely. `[click](ref)`
+// with `[ref]: javascript:alert(1)` defined two hundred lines below passes any
+// check that reads the inline text, because at that point the href is the word
+// `ref`. The only place that sees the URL the renderer is actually about to put
+// in the DOM is the renderer's component override or link callback — so that is
+// where this belongs, on the final resolved value, for BOTH `a` and `img`.
+//
+// WHY WHITESPACE AND CONTROL CHARACTERS ARE STRIPPED FIRST
+//
+// Browsers strip leading and trailing whitespace from URLs and remove tab, CR
+// and LF from anywhere inside them before parsing. So `java\tscript:alert(1)`
+// and ` javascript:alert(1)` are both live `javascript:` URLs in a browser, and
+// both slip past a naive `startsWith('javascript:')`. Cleaning to what the
+// browser will see and then validating THAT is the only order that is correct.
+// The cleaned string is also what is returned, so the value checked is the value
+// emitted rather than a second one that happens to look similar.
+//
+// WHAT IS ALLOWED
+//
+// * `http:` and `https:` — ordinary links.
+// * `mailto:` — on links only. In an `src` it is meaningless.
+// * `#anchor` — in-page, on links only.
+// * anything with no scheme at all — relative, resolved against the site.
+//
+// Everything else is dropped: `javascript:` first among them, but also `data:`
+// (a data URL can carry a whole HTML document), `vbscript:`, `file:` and every
+// scheme nobody has thought of yet. The list is an allowlist rather than a
+// blocklist for exactly that reason.
+
+// Removes what a browser removes before parsing a URL: every C0 control
+// character, DEL, and leading/trailing whitespace. Interior spaces are left
+// alone — they cannot form a scheme, and a browser percent-encodes them.
+const asBrowserWouldSee = (href: string): string =>
+ href.replace(/[\u0000-\u001f\u007f]/g, '').trim();
+
+// What the URL might mean once something else has decoded it.
+//
+// A markdown parser typically percent-encodes control characters before this
+// code sees the href, so `java\tscript:alert(1)` can arrive here as
+// `java%09script:alert(1)` — which has no scheme by the letter of the rule and
+// would be waved through as a relative path. A browser reads it that way too,
+// so it is inert TODAY. But it would be waved through for a reason that is a
+// property of somebody else's encoder rather than of this allowlist, which is
+// not a footing to stand a security control on.
+//
+// So the decision is made on a probe with those encodings removed, while the
+// value RETURNED is the untouched cleaned one. Rejection is judged against the
+// most dangerous reading; emission is never a string this function invented.
+const probeOf = (href: string): string => href.replace(/%(0[0-9a-f]|1[0-9a-f]|20|7f)/gi, '');
+
+// The scheme, lower-cased, or null when the value carries none. A URL with no
+// scheme is relative and resolves against the current origin, which is safe by
+// construction.
+//
+// The pattern is RFC 3986's: a scheme starts with a letter and continues with
+// letters, digits, `+`, `-` and `.`. Anchored, so `/a:b` — a path containing a
+// colon — is correctly read as having no scheme rather than as scheme `/a`.
+const schemeOf = (href: string): string | null => {
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(href);
+ return match ? match[1].toLowerCase() : null;
+};
+
+export const ALLOWED_LINK_SCHEMES: readonly string[] = ['http', 'https', 'mailto'];
+export const ALLOWED_IMAGE_SCHEMES: readonly string[] = ['http', 'https'];
+
+/** A safe `href`, or null when the element must render as plain text instead. */
+export const safeLinkUrl = (href: string | undefined | null): string | null => {
+ const cleaned = asBrowserWouldSee(href ?? '');
+ if (cleaned === '') {
+ return null;
+ }
+ if (cleaned.startsWith('#')) {
+ // In-page. A fragment cannot carry a scheme and cannot leave the page.
+ return cleaned;
+ }
+ const scheme = schemeOf(probeOf(cleaned));
+ if (scheme === null) {
+ return cleaned;
+ }
+ return ALLOWED_LINK_SCHEMES.includes(scheme) ? cleaned : null;
+};
+
+/**
+ * A safe `src`, or null. Narrower than a link: `mailto:` and `#anchor` are not
+ * images, and admitting them would mean an `
` with a nonsense source rather
+ * than no image at all.
+ *
+ * Note that an image embedded by URL is still a privacy leak — it discloses the
+ * reader's IP address to whatever host is named — even when it is not an XSS
+ * vector. Proxying images, or narrowing this to a set of known hosts, is a
+ * decision for the site rather than for this package, and it is an `img-src`
+ * change in the site's Content-Security-Policy as much as a change here.
+ */
+export const safeImageUrl = (src: string | undefined | null): string | null => {
+ const cleaned = asBrowserWouldSee(src ?? '');
+ if (cleaned === '' || cleaned.startsWith('#')) {
+ return null;
+ }
+ const scheme = schemeOf(probeOf(cleaned));
+ if (scheme === null) {
+ return cleaned;
+ }
+ return ALLOWED_IMAGE_SCHEMES.includes(scheme) ? cleaned : null;
+};
+
+/**
+ * Whether a resolved URL leaves the current site, and therefore needs
+ * `target="_blank"` and `rel="noopener noreferrer"`. Relative URLs and in-page
+ * anchors do not.
+ */
+export const isExternalUrl = (href: string): boolean => /^(https?:)?\/\//i.test(href.trim());
diff --git a/js/src/repo.ts b/js/src/repo.ts
new file mode 100644
index 0000000..c19b81e
--- /dev/null
+++ b/js/src/repo.ts
@@ -0,0 +1,64 @@
+// Repository identity: turning whatever a human typed into the `owner/repo`
+// slug the rest of this package sends to GitHub, and back into URLs a reader
+// can open.
+//
+// Pure and dependency-free: nothing here reads the environment or touches the
+// network. The slug arrives as an argument, read once by whatever configures
+// the client. Keeping it pure is what lets these rules be unit-tested
+// exhaustively.
+
+// A repository slug that GitHub will accept. Owner and repository names are
+// letters, digits, dot, dash and underscore; anything else — a path segment, a
+// query string, whitespace, a `..` — is not a slug and is refused rather than
+// escaped, because a slug that needs escaping is a typo or an attack and
+// neither should reach the network.
+const SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
+
+// Strip the wrapping a human leaves behind: surrounding whitespace, leading and
+// trailing slashes, a trailing `.git`. These values usually come from an
+// environment variable someone typed by hand, and none of that wrapping changes
+// which repository is meant.
+const tidy = (value: string): string =>
+ value.trim().replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
+
+/**
+ * The `owner/repo` slug for a repository, or null when the input is not one.
+ *
+ * Accepts a bare slug (`acme/handbook`) or a GitHub URL in any of the shapes
+ * people paste — `https://github.com/acme/handbook`, with or without a
+ * trailing `.git`, `/tree/main`, a fragment or a query string.
+ */
+export const parseRepoSlug = (value: string | undefined | null): string | null => {
+ const raw = (value ?? '').trim();
+ if (raw === '') {
+ return null;
+ }
+ const fromUrl = /github\.com\/([^/#?]+)\/([^/#?]+)/i.exec(raw);
+ const candidate = fromUrl ? `${fromUrl[1]}/${fromUrl[2]}` : raw;
+ const slug = tidy(candidate);
+ return SLUG_PATTERN.test(slug) ? slug : null;
+};
+
+/**
+ * The same as {@link parseRepoSlug}, but throws on an unusable value.
+ *
+ * This is the one place in the package that throws, and it does so at
+ * construction time rather than at fetch time: a misconfigured repository is a
+ * deployment mistake to fix, not an upstream outage to render a panel about.
+ * Once a client exists, every later failure is a value.
+ */
+export const requireRepoSlug = (value: string | undefined | null): string => {
+ const slug = parseRepoSlug(value);
+ if (slug === null) {
+ // The offending value is quoted here because it is the caller's own
+ // configuration, not anything that came back from GitHub.
+ throw new Error(`github-docs: ${JSON.stringify(value)} is not an "owner/repo" repository slug or GitHub URL`);
+ }
+ return slug;
+};
+
+/** `owner/repo` -> the repository's web URL. */
+export const repoWebUrl = (repo: string): string => `https://github.com/${requireRepoSlug(repo)}`;
+
+/** The repository's releases page. */
+export const releasesUrl = (repo: string): string => `${repoWebUrl(repo)}/releases`;
diff --git a/js/src/urls.ts b/js/src/urls.ts
new file mode 100644
index 0000000..fde1de5
--- /dev/null
+++ b/js/src/urls.ts
@@ -0,0 +1,120 @@
+// Where a document lives: the plain-text URL this package fetches, the URL a
+// human is sent to when the fetch fails, and the rewrite that keeps links
+// inside a fetched document pointing somewhere useful.
+//
+// Pure and dependency-free. Every function takes the repository slug as an
+// argument.
+
+import {repoWebUrl, requireRepoSlug} from './repo.js';
+
+/**
+ * The default git ref.
+ *
+ * `HEAD` resolves to whatever the repository's default branch is called, so
+ * nothing here has to know whether that is `main`, `master` or something a
+ * community picked for itself. Both github.com and raw.githubusercontent.com
+ * accept it.
+ */
+export const DEFAULT_REF = 'HEAD';
+
+// A path inside the repository, encoded the way a URL needs while leaving the
+// separators alone. `encodeURIComponent` on the whole path would escape the
+// slashes and ask GitHub for one long filename.
+export const encodeDocPath = (path: string): string =>
+ path.split('/').map(encodeURIComponent).join('/');
+
+const tidyPath = (path: string): string => path.trim().replace(/^\/+/, '');
+
+/** The plain-text URL of a document, for fetching. */
+export const rawUrl = (repo: string, path: string, ref: string = DEFAULT_REF): string =>
+ `https://raw.githubusercontent.com/${requireRepoSlug(repo)}/${encodeURIComponent(ref)}/${encodeDocPath(tidyPath(path))}`;
+
+/** The document as a human reads it on GitHub. */
+export const blobUrl = (repo: string, path: string, ref: string = DEFAULT_REF): string =>
+ `${repoWebUrl(repo)}/blob/${encodeURIComponent(ref)}/${encodeDocPath(tidyPath(path))}`;
+
+/** The GitHub Contents API endpoint for a document. */
+export const contentsApiUrl = (
+ repo: string,
+ path: string,
+ ref: string = DEFAULT_REF,
+ apiBase: string = 'https://api.github.com'
+): string => {
+ const base = apiBase.replace(/\/+$/, '');
+ const url = `${base}/repos/${requireRepoSlug(repo)}/contents/${encodeDocPath(tidyPath(path))}`;
+ // `HEAD` is the Contents API's own default, so it is left off rather than
+ // sent as a literal ref the API would have to resolve as a branch name.
+ return ref === DEFAULT_REF ? url : `${url}?ref=${encodeURIComponent(ref)}`;
+};
+
+/**
+ * A link target that already says where it goes: an absolute URL (`https:`,
+ * `mailto:`), a protocol-relative one (`//host/path`), or an in-page anchor
+ * (`#rules`). Everything else in a document written for GitHub resolves
+ * against the repository.
+ */
+export const isSelfContainedTarget = (href: string): boolean =>
+ href.startsWith('#') || href.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(href);
+
+/**
+ * Rewrite a link found inside a fetched document so it still goes where its
+ * author meant.
+ *
+ * `[Rules](rules.md)` works on GitHub because the document is read from inside
+ * the repository. Rendered on a website at `/docs/getting-started`, the browser
+ * would resolve that against the site and 404. Relative targets are therefore
+ * pointed back at the source repository on the same ref, with any `#fragment`
+ * carried along untouched. Self-contained targets are returned unchanged.
+ *
+ * `fromPath` is the path of the document the link was found in, so
+ * document-relative targets resolve against its directory the way they do on
+ * GitHub. Omit it for a repository whose documents all sit at the root.
+ */
+export const resolveDocLink = (
+ repo: string,
+ href: string,
+ options: {fromPath?: string; ref?: string} = {}
+): string => {
+ if (href === '' || isSelfContainedTarget(href)) {
+ return href;
+ }
+ const ref = options.ref ?? DEFAULT_REF;
+ const [target, ...fragmentParts] = href.split('#');
+ const fragment = fragmentParts.length > 0 ? `#${fragmentParts.join('#')}` : '';
+ if (target === '') {
+ return href;
+ }
+ const resolved = resolveRepoPath(options.fromPath ?? '', target);
+ if (resolved === null) {
+ // The link climbs out of the repository. There is nothing above the
+ // repository root to point at, so it is left as written rather than
+ // turned into a URL that is confidently wrong.
+ return href;
+ }
+ return `${blobUrl(repo, resolved, ref)}${fragment}`;
+};
+
+/**
+ * Resolve a relative path against the directory of the document containing it,
+ * the way a POSIX path resolves. Returns null if it escapes the repository
+ * root — which is a link that was already broken on GitHub.
+ */
+export const resolveRepoPath = (fromPath: string, href: string): string | null => {
+ const base = href.startsWith('/') ? [] : fromPath.split('/').slice(0, -1);
+ const segments = [...base, ...href.replace(/^\//, '').split('/')];
+ const resolved: string[] = [];
+ for (const segment of segments) {
+ if (segment === '' || segment === '.') {
+ continue;
+ }
+ if (segment === '..') {
+ if (resolved.length === 0) {
+ return null;
+ }
+ resolved.pop();
+ continue;
+ }
+ resolved.push(segment);
+ }
+ return resolved.length === 0 ? null : resolved.join('/');
+};
diff --git a/js/test/catalogue.test.ts b/js/test/catalogue.test.ts
new file mode 100644
index 0000000..a15b2b3
--- /dev/null
+++ b/js/test/catalogue.test.ts
@@ -0,0 +1,115 @@
+import {describe, expect, it} from 'vitest';
+
+import {
+ entryForPath,
+ entryForSlug,
+ isSafeDocPath,
+ parseCatalogue,
+ slugForPath,
+ titleForSlug
+} from '../src/catalogue';
+
+describe('isSafeDocPath', () => {
+ it('accepts an ordinary path in a repository', () => {
+ expect(isSafeDocPath('handbook/the-rules.md')).toBe(true);
+ expect(isSafeDocPath('README.md')).toBe(true);
+ expect(isSafeDocPath('a/b/c_1.2.md')).toBe(true);
+ });
+
+ it('refuses anything that is not one, rather than escaping it', () => {
+ for (const path of [
+ '../secrets.md',
+ 'handbook/../../etc/passwd',
+ '/handbook/rules.md',
+ 'handbook//rules.md',
+ 'handbook\\rules.md',
+ 'handbook/rules.md?ref=main',
+ 'https://example.com/rules.md',
+ 'handbook/the rules.md',
+ './rules.md',
+ ''
+ ]) {
+ expect(isSafeDocPath(path), path).toBe(false);
+ }
+ });
+});
+
+describe('slugForPath and titleForSlug', () => {
+ it('turns a path into a URL segment', () => {
+ expect(slugForPath('handbook/The Rules.md')).toBe('the-rules');
+ expect(slugForPath('handbook/getting-started.mdx')).toBe('getting-started');
+ expect(slugForPath('rules.md')).toBe('rules');
+ });
+
+ it('turns a slug into an honest approximation of a title', () => {
+ expect(titleForSlug('getting-started')).toBe('Getting Started');
+ expect(titleForSlug('rules')).toBe('Rules');
+ expect(titleForSlug('')).toBe('');
+ });
+});
+
+describe('parseCatalogue', () => {
+ it('reads the comma-separated string an environment variable comes in', () => {
+ expect(parseCatalogue('handbook/rules.md, handbook/getting-started.md')).toEqual([
+ {slug: 'rules', path: 'handbook/rules.md', title: 'Rules'},
+ {slug: 'getting-started', path: 'handbook/getting-started.md', title: 'Getting Started'}
+ ]);
+ });
+
+ it('preserves the order it was given', () => {
+ expect(parseCatalogue(['c.md', 'a.md', 'b.md']).map((entry) => entry.slug)).toEqual(['c', 'a', 'b']);
+ });
+
+ // Deny-by-default. An operator who has not filled the list in publishes
+ // nothing, rather than publishing the whole repository by accident.
+ it('treats unset, empty and unusable input as "nothing is published"', () => {
+ expect(parseCatalogue(undefined)).toEqual([]);
+ expect(parseCatalogue(null)).toEqual([]);
+ expect(parseCatalogue('')).toEqual([]);
+ expect(parseCatalogue(' , ,')).toEqual([]);
+ expect(parseCatalogue(['../escape.md', '/absolute.md'])).toEqual([]);
+ });
+
+ it('drops one bad entry without taking the rest of the list down', () => {
+ expect(parseCatalogue(['handbook/rules.md', '../escape.md', 'handbook/faq.md']).map((e) => e.slug))
+ .toEqual(['rules', 'faq']);
+ });
+
+ // Serving one document at another's URL is the worse failure.
+ it('keeps the first of two entries that would fight over one URL', () => {
+ expect(parseCatalogue(['handbook/rules.md', 'archive/rules.md'])).toEqual([
+ {slug: 'rules', path: 'handbook/rules.md', title: 'Rules'}
+ ]);
+ });
+
+ it('lets a caller override the derived slug, title and summary', () => {
+ expect(
+ parseCatalogue([
+ {path: 'handbook/getting-started.md', slug: 'start', title: 'Start here', summary: 'Your first hour.'}
+ ])
+ ).toEqual([
+ {
+ slug: 'start',
+ path: 'handbook/getting-started.md',
+ title: 'Start here',
+ summary: 'Your first hour.'
+ }
+ ]);
+ });
+});
+
+describe('lookups', () => {
+ const entries = parseCatalogue(['handbook/rules.md', 'handbook/faq.md']);
+
+ it('resolves a known slug and refuses an unknown one', () => {
+ expect(entryForSlug(entries, 'rules')?.path).toBe('handbook/rules.md');
+ expect(entryForSlug(entries, 'nope')).toBeNull();
+ expect(entryForSlug(entries, '')).toBeNull();
+ expect(entryForSlug(entries, '../rules')).toBeNull();
+ });
+
+ it('resolves by repository path too', () => {
+ expect(entryForPath(entries, 'handbook/faq.md')?.slug).toBe('faq');
+ expect(entryForPath(entries, 'secrets.md')).toBeNull();
+ });
+});
diff --git a/js/test/client.test.ts b/js/test/client.test.ts
new file mode 100644
index 0000000..a968d5c
--- /dev/null
+++ b/js/test/client.test.ts
@@ -0,0 +1,274 @@
+import {afterEach, describe, expect, it, vi} from 'vitest';
+
+import {createDocsClient, type MarkdownFetch} from '../src/client';
+
+const REPO = 'acme-guild/handbook';
+const DOCS = ['handbook/rules.md', 'handbook/getting-started.md'];
+
+// A credential-shaped string, and a body that quotes it back. A real GitHub
+// error body does not echo the credential, but an upstream that did — a
+// misconfigured proxy, a future API, an intermediary — must not be able to
+// launder it through this package.
+const TOKEN = 'ghp_a1b2c3d4e5f6g7h8i9j0_secretvalue';
+const LEAKY_BODY = `{"message":"Bad credentials: ${TOKEN}","documentation_url":"https://docs.github.com/rest"}`;
+
+const okResponse = (text: string): Response =>
+ ({ok: true, status: 200, text: async () => text} as unknown as Response);
+
+const errorResponse = (status: number, body: string): Response =>
+ ({ok: false, status, statusText: 'Unauthorized', text: async () => body} as unknown as Response);
+
+// Everything a result could possibly carry, flattened to one string. If the
+// token or the body is anywhere in the returned value — including on a property
+// nobody thought to check — this catches it.
+const everythingIn = (result: MarkdownFetch): string =>
+ [
+ JSON.stringify(result),
+ ...Object.getOwnPropertyNames(result).map(
+ (key) => `${key}=${String((result as unknown as Record)[key])}`
+ )
+ ].join(' | ');
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('the happy path', () => {
+ it('returns the document', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(okResponse('# Rules\n'));
+ const client = createDocsClient({repo: REPO, documents: DOCS, fetchImpl: fetchImpl as never});
+
+ await expect(client.fetchMarkdown('rules')).resolves.toEqual({status: 'ok', markdown: '# Rules\n'});
+ expect(fetchImpl).toHaveBeenCalledOnce();
+ expect(fetchImpl.mock.calls[0][0]).toBe(
+ 'https://raw.githubusercontent.com/acme-guild/handbook/HEAD/handbook/rules.md'
+ );
+ });
+
+ it('accepts a slug, a repository path, or a catalogue entry', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(okResponse('body'));
+ const client = createDocsClient({repo: REPO, documents: DOCS, fetchImpl: fetchImpl as never});
+
+ for (const target of ['rules', 'handbook/rules.md', client.entryForSlug('rules')!]) {
+ await expect(client.fetchMarkdown(target)).resolves.toEqual({status: 'ok', markdown: 'body'});
+ }
+ });
+});
+
+describe('failure is a value, not an exception', () => {
+ // The point of the whole module: none of these reject, so no caller has to
+ // remember to catch, and none of them can turn into a 5xx.
+ const cases: [string, () => unknown][] = [
+ ['a 404', () => vi.fn().mockResolvedValue(errorResponse(404, 'Not Found'))],
+ ['a rate limit', () => vi.fn().mockResolvedValue(errorResponse(403, 'rate limit exceeded'))],
+ ['an upstream 500', () => vi.fn().mockResolvedValue(errorResponse(500, 'Server Error'))],
+ ['a network failure', () => vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND api.github.com'))],
+ ['an abort', () => vi.fn().mockRejectedValue(Object.assign(new Error('aborted'), {name: 'AbortError'}))],
+ ['a body that never resolves to text', () => vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => {
+ throw new Error('unexpected end of stream');
+ }
+ })]
+ ];
+
+ for (const [name, makeFetch] of cases) {
+ it(`collapses ${name} to unavailable`, async () => {
+ const client = createDocsClient({repo: REPO, documents: DOCS, fetchImpl: makeFetch() as never});
+ await expect(client.fetchMarkdown('rules')).resolves.toEqual({status: 'unavailable'});
+ });
+ }
+
+ it('refuses a document larger than the limit rather than holding it in memory', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(okResponse('x'.repeat(101)));
+ const client = createDocsClient({
+ repo: REPO,
+ documents: DOCS,
+ maxDocumentBytes: 100,
+ fetchImpl: fetchImpl as never
+ });
+ await expect(client.fetchMarkdown('rules')).resolves.toEqual({status: 'unavailable'});
+ });
+
+ it('gives up after the timeout by aborting the request', async () => {
+ const fetchImpl = vi.fn(
+ (_url: string, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
+ })
+ );
+ const client = createDocsClient({
+ repo: REPO,
+ documents: DOCS,
+ timeoutMs: 5,
+ fetchImpl: fetchImpl as never
+ });
+ await expect(client.fetchMarkdown('rules')).resolves.toEqual({status: 'unavailable'});
+ });
+});
+
+describe('nothing upstream is ever quoted back', () => {
+ it('does not put a failing response body or the token into the returned value', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(errorResponse(401, LEAKY_BODY));
+ const client = createDocsClient({
+ repo: REPO,
+ documents: DOCS,
+ token: TOKEN,
+ fetchImpl: fetchImpl as never
+ });
+
+ const result = await client.fetchMarkdown('rules');
+
+ expect(result).toEqual({status: 'unavailable'});
+ const serialised = everythingIn(result);
+ expect(serialised).not.toContain(TOKEN);
+ expect(serialised).not.toContain('Bad credentials');
+ expect(serialised).not.toContain('documentation_url');
+ expect(serialised).not.toContain('401');
+ // Not even the URL, which names the private repository.
+ expect(serialised).not.toContain('api.github.com');
+ expect(serialised).not.toContain(REPO);
+ });
+
+ it('does not read the body of a failing response at all', async () => {
+ const text = vi.fn(async () => LEAKY_BODY);
+ const fetchImpl = vi.fn().mockResolvedValue({ok: false, status: 500, text});
+ const client = createDocsClient({
+ repo: REPO,
+ documents: DOCS,
+ token: TOKEN,
+ fetchImpl: fetchImpl as never
+ });
+
+ await client.fetchMarkdown('rules');
+
+ expect(text).not.toHaveBeenCalled();
+ });
+
+ it('does not put an upstream error message into the returned value', async () => {
+ const fetchImpl = vi.fn().mockRejectedValue(new Error(`connect ECONNREFUSED using ${TOKEN}`));
+ const client = createDocsClient({
+ repo: REPO,
+ documents: DOCS,
+ token: TOKEN,
+ fetchImpl: fetchImpl as never
+ });
+
+ const result = await client.fetchMarkdown('rules');
+ expect(everythingIn(result)).not.toContain(TOKEN);
+ expect(everythingIn(result)).not.toContain('ECONNREFUSED');
+ });
+
+ it('sends the token in a header and never in a URL', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(okResponse('# Rules'));
+ const client = createDocsClient({
+ repo: REPO,
+ documents: DOCS,
+ token: TOKEN,
+ fetchImpl: fetchImpl as never
+ });
+
+ await client.fetchMarkdown('rules');
+
+ const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
+ expect(url).not.toContain(TOKEN);
+ expect(url).toBe('https://api.github.com/repos/acme-guild/handbook/contents/handbook/rules.md');
+ expect((init.headers as Record).Authorization).toBe(`Bearer ${TOKEN}`);
+ // The raw-content host does not accept a bearer token, so nothing that
+ // would send one there is used when a token is configured.
+ expect(url).not.toContain('raw.githubusercontent.com');
+ });
+
+ it('sends no credentials at all to the public raw host', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(okResponse('# Rules'));
+ const client = createDocsClient({repo: REPO, documents: DOCS, fetchImpl: fetchImpl as never});
+
+ await client.fetchMarkdown('rules');
+
+ const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
+ expect(url).toContain('raw.githubusercontent.com');
+ expect(Object.keys(init.headers as Record)).toEqual(['Accept']);
+ });
+});
+
+describe('deployment states are not outages', () => {
+ it('says not-configured when the API transport has no token', async () => {
+ const fetchImpl = vi.fn();
+ const client = createDocsClient({
+ repo: REPO,
+ documents: DOCS,
+ transport: 'api',
+ fetchImpl: fetchImpl as never
+ });
+
+ await expect(client.fetchMarkdown('rules')).resolves.toEqual({status: 'not-configured'});
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('treats a blank token as no token', async () => {
+ const client = createDocsClient({repo: REPO, documents: DOCS, transport: 'api', token: ' '});
+ await expect(client.fetchMarkdown('rules')).resolves.toEqual({status: 'not-configured'});
+ });
+});
+
+describe('the catalogue gate runs before the fetch, not after', () => {
+ it('refuses an unlisted path without contacting GitHub', async () => {
+ const fetchImpl = vi.fn();
+ const client = createDocsClient({repo: REPO, documents: DOCS, fetchImpl: fetchImpl as never});
+
+ for (const target of ['secrets.md', '../../etc/passwd', 'infrastructure/main.tf', 'nope', '']) {
+ await expect(client.fetchMarkdown(target), target).resolves.toEqual({status: 'not-listed'});
+ }
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('publishes nothing when the catalogue is configured but empty', async () => {
+ const fetchImpl = vi.fn();
+ const client = createDocsClient({repo: REPO, documents: [], fetchImpl: fetchImpl as never});
+
+ expect(client.documents).toEqual([]);
+ await expect(client.fetchMarkdown('rules')).resolves.toEqual({status: 'not-listed'});
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('serves any valid path when no catalogue is configured at all', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(okResponse('anything'));
+ const client = createDocsClient({repo: REPO, fetchImpl: fetchImpl as never});
+
+ await expect(client.fetchMarkdown('whatever/doc.md')).resolves.toEqual({
+ status: 'ok',
+ markdown: 'anything'
+ });
+ // Still not a traversal, though: an unsafe path is not a path.
+ await expect(client.fetchMarkdown('../../etc/passwd')).resolves.toEqual({status: 'not-listed'});
+ });
+});
+
+describe('the rest of the client surface', () => {
+ const client = createDocsClient({repo: 'https://github.com/acme-guild/handbook.git', documents: DOCS});
+
+ it('normalises the repository it was configured with', () => {
+ expect(client.repo).toBe(REPO);
+ expect(client.ref).toBe('HEAD');
+ });
+
+ it('exposes the catalogue in configured order', () => {
+ expect(client.documents.map((entry) => entry.slug)).toEqual(['rules', 'getting-started']);
+ expect(client.entryForPath('handbook/rules.md')?.title).toBe('Rules');
+ });
+
+ it('builds the URLs a page needs', () => {
+ expect(client.webUrl('rules')).toBe('https://github.com/acme-guild/handbook/blob/HEAD/handbook/rules.md');
+ expect(client.rawUrl('rules')).toBe(
+ 'https://raw.githubusercontent.com/acme-guild/handbook/HEAD/handbook/rules.md'
+ );
+ expect(client.resolveLink('getting-started.md', 'handbook/rules.md')).toBe(
+ 'https://github.com/acme-guild/handbook/blob/HEAD/handbook/getting-started.md'
+ );
+ });
+
+ it('throws on a URL builder for an unlisted document, since that is a programming mistake', () => {
+ expect(() => client.webUrl('secrets.md')).toThrowError(/not a published document/);
+ });
+});
diff --git a/js/test/markdownUrl.test.ts b/js/test/markdownUrl.test.ts
new file mode 100644
index 0000000..f4dc8ab
--- /dev/null
+++ b/js/test/markdownUrl.test.ts
@@ -0,0 +1,121 @@
+import {describe, expect, it} from 'vitest';
+
+import {isExternalUrl, safeImageUrl, safeLinkUrl} from '../src/markdownUrl';
+
+// The URL-scheme allowlist, on its own. This file is about the decision itself,
+// and specifically about the ways a `javascript:` URL gets past a check that
+// looks obviously correct.
+
+describe('safeLinkUrl', () => {
+ it('allows the four things a link may be', () => {
+ expect(safeLinkUrl('https://example.com/page')).toBe('https://example.com/page');
+ expect(safeLinkUrl('http://example.com')).toBe('http://example.com');
+ expect(safeLinkUrl('mailto:someone@example.com')).toBe('mailto:someone@example.com');
+ expect(safeLinkUrl('#rules')).toBe('#rules');
+ expect(safeLinkUrl('/handbook/general')).toBe('/handbook/general');
+ expect(safeLinkUrl('../sibling')).toBe('../sibling');
+ });
+
+ it('refuses every scheme that is not on the list', () => {
+ for (const href of [
+ 'javascript:alert(1)',
+ // A data URL can carry a whole HTML document, which is why the list
+ // is an allowlist and not "block javascript:".
+ 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
+ 'vbscript:msgbox(1)',
+ 'file:///etc/passwd',
+ 'about:blank',
+ 'blob:https://example.com/1234'
+ ]) {
+ expect(safeLinkUrl(href), href).toBeNull();
+ }
+ });
+
+ it('refuses the spellings a browser still executes', () => {
+ // Every one of these is a live javascript: URL in a browser, and every
+ // one of them survives `href.startsWith('javascript:')`. Browsers strip
+ // leading and trailing whitespace and remove tab, CR and LF from
+ // anywhere inside a URL before parsing it — so the check has to run on
+ // the cleaned string, which is what this asserts.
+ for (const href of [
+ 'JavaScript:alert(1)',
+ 'JAVASCRIPT:alert(1)',
+ ' javascript:alert(1) ',
+ '\tjavascript:alert(1)',
+ '\njavascript:alert(1)',
+ 'java\tscript:alert(1)',
+ 'java\nscript:alert(1)',
+ 'java\rscript:alert(1)',
+ 'jav\u0000ascript:alert(1)',
+ '\u0001javascript:alert(1)',
+ 'javascript\u0000:alert(1)'
+ ]) {
+ expect(safeLinkUrl(href), JSON.stringify(href)).toBeNull();
+ }
+ });
+
+ it('refuses the percent-encoded spellings a decoder downstream might revive', () => {
+ // The probe is what makes these fail. They are inert in a browser
+ // today, but only because of somebody else's encoder.
+ for (const href of ['java%09script:alert(1)', 'java%0Ascript:alert(1)', '%20javascript:alert(1)']) {
+ expect(safeLinkUrl(href), href).toBeNull();
+ }
+ });
+
+ it('returns the CLEANED value, so what is checked is what is emitted', () => {
+ // If the padded value were returned instead, the check would have run
+ // on one string and the DOM would have received another.
+ expect(safeLinkUrl(' https://example.com/a ')).toBe('https://example.com/a');
+ expect(safeLinkUrl('https://exam\tple.com/a')).toBe('https://example.com/a');
+ });
+
+ it('reads a colon in a path as a path rather than as a scheme', () => {
+ // RFC 3986: a scheme starts with a LETTER. `/a:b` and `./a:b` are
+ // relative paths, and refusing them would drop legitimate links for no
+ // gain.
+ expect(safeLinkUrl('/notes/a:b')).toBe('/notes/a:b');
+ expect(safeLinkUrl('./a:b')).toBe('./a:b');
+ expect(safeLinkUrl('9lives:x')).toBe('9lives:x');
+ });
+
+ it('treats nothing as nothing', () => {
+ expect(safeLinkUrl('')).toBeNull();
+ expect(safeLinkUrl(' ')).toBeNull();
+ expect(safeLinkUrl(undefined)).toBeNull();
+ expect(safeLinkUrl(null)).toBeNull();
+ });
+});
+
+describe('safeImageUrl', () => {
+ it('is narrower than a link', () => {
+ expect(safeImageUrl('https://example.com/a.png')).toBe('https://example.com/a.png');
+ expect(safeImageUrl('/static/a.png')).toBe('/static/a.png');
+ // Neither is an image. Admitting them would mean an
with a
+ // nonsense source rather than no image at all.
+ expect(safeImageUrl('mailto:someone@example.com')).toBeNull();
+ expect(safeImageUrl('#anchor')).toBeNull();
+ });
+
+ it('refuses the same hostile schemes, including on an attribute a href-only rule would miss', () => {
+ for (const src of [
+ 'javascript:alert(1)',
+ 'data:text/html,',
+ 'DATA:image/svg+xml,