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 : {alt; +}; + +{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,', + ' java\tscript:alert(1)' + ]) { + expect(safeImageUrl(src), src).toBeNull(); + } + }); +}); + +describe('isExternalUrl', () => { + it('is true only for something that leaves this site', () => { + expect(isExternalUrl('https://example.com')).toBe(true); + expect(isExternalUrl('http://example.com')).toBe(true); + expect(isExternalUrl('//example.com/a')).toBe(true); + + expect(isExternalUrl('/handbook')).toBe(false); + expect(isExternalUrl('#anchor')).toBe(false); + expect(isExternalUrl('mailto:a@b.c')).toBe(false); + }); +}); diff --git a/js/test/urls.test.ts b/js/test/urls.test.ts new file mode 100644 index 0000000..6580989 --- /dev/null +++ b/js/test/urls.test.ts @@ -0,0 +1,150 @@ +import {describe, expect, it} from 'vitest'; + +import {parseRepoSlug, releasesUrl, repoWebUrl, requireRepoSlug} from '../src/repo'; +import { + blobUrl, + contentsApiUrl, + isSelfContainedTarget, + rawUrl, + resolveDocLink, + resolveRepoPath +} from '../src/urls'; + +const REPO = 'acme-guild/handbook'; + +describe('parseRepoSlug', () => { + it('accepts a bare slug', () => { + expect(parseRepoSlug('acme-guild/handbook')).toBe(REPO); + }); + + it('tolerates the slashes and spaces a human puts in an environment variable', () => { + expect(parseRepoSlug(' /acme-guild/handbook/ ')).toBe(REPO); + }); + + it('accepts the URL shapes people actually paste', () => { + expect(parseRepoSlug('https://github.com/acme-guild/handbook')).toBe(REPO); + expect(parseRepoSlug('https://github.com/acme-guild/handbook.git')).toBe(REPO); + expect(parseRepoSlug('https://github.com/acme-guild/handbook/tree/main/docs')).toBe(REPO); + expect(parseRepoSlug('http://GitHub.com/acme-guild/handbook?tab=readme')).toBe(REPO); + }); + + it('returns null rather than guessing', () => { + for (const value of ['', ' ', 'handbook', 'acme-guild/', '/handbook', 'a/b/c', '../../etc', undefined, null]) { + expect(parseRepoSlug(value as string | undefined | null), String(value)).toBeNull(); + } + }); + + // The one place this package throws, and it throws on the caller's own + // configuration rather than on anything upstream. + it('throws from requireRepoSlug, quoting only the value the caller passed', () => { + expect(() => requireRepoSlug('not a repo')).toThrowError(/"not a repo"/); + }); +}); + +describe('repository URLs', () => { + it('builds the web and releases URLs', () => { + expect(repoWebUrl(REPO)).toBe('https://github.com/acme-guild/handbook'); + expect(releasesUrl('https://github.com/acme-guild/handbook')).toBe( + 'https://github.com/acme-guild/handbook/releases' + ); + }); +}); + +describe('document URLs', () => { + it('builds the raw URL the site fetches', () => { + expect(rawUrl(REPO, 'handbook/rules.md')).toBe( + 'https://raw.githubusercontent.com/acme-guild/handbook/HEAD/handbook/rules.md' + ); + }); + + it('builds the URL a reader is sent to when the fetch fails', () => { + expect(blobUrl(REPO, 'handbook/rules.md')).toBe( + 'https://github.com/acme-guild/handbook/blob/HEAD/handbook/rules.md' + ); + }); + + it('honours an explicit ref', () => { + expect(rawUrl(REPO, 'rules.md', 'v2')).toBe( + 'https://raw.githubusercontent.com/acme-guild/handbook/v2/rules.md' + ); + expect(blobUrl(REPO, 'rules.md', 'v2')).toBe('https://github.com/acme-guild/handbook/blob/v2/rules.md'); + }); + + it('encodes the path without eating its separators', () => { + expect(rawUrl(REPO, 'a b/c&d.md')).toBe( + 'https://raw.githubusercontent.com/acme-guild/handbook/HEAD/a%20b/c%26d.md' + ); + }); + + it('leaves HEAD off the Contents API call, since that is its own default', () => { + expect(contentsApiUrl(REPO, 'handbook/rules.md')).toBe( + 'https://api.github.com/repos/acme-guild/handbook/contents/handbook/rules.md' + ); + expect(contentsApiUrl(REPO, 'handbook/rules.md', 'v2')).toBe( + 'https://api.github.com/repos/acme-guild/handbook/contents/handbook/rules.md?ref=v2' + ); + }); +}); + +describe('resolveRepoPath', () => { + it('resolves against the directory of the document containing the link', () => { + expect(resolveRepoPath('handbook/getting-started.md', 'rules.md')).toBe('handbook/rules.md'); + expect(resolveRepoPath('handbook/getting-started.md', './rules.md')).toBe('handbook/rules.md'); + expect(resolveRepoPath('handbook/getting-started.md', '../faq.md')).toBe('faq.md'); + expect(resolveRepoPath('handbook/getting-started.md', '/docs/rules.md')).toBe('docs/rules.md'); + }); + + it('refuses a link that climbs out of the repository', () => { + expect(resolveRepoPath('handbook/getting-started.md', '../../etc/passwd')).toBeNull(); + }); +}); + +describe('resolveDocLink', () => { + it('leaves a link that already says where it goes alone', () => { + expect(resolveDocLink(REPO, '#commands')).toBe('#commands'); + expect(resolveDocLink(REPO, 'https://example.test/x')).toBe('https://example.test/x'); + expect(resolveDocLink(REPO, '//example.test/x')).toBe('//example.test/x'); + expect(resolveDocLink(REPO, 'mailto:staff@example.test')).toBe('mailto:staff@example.test'); + expect(resolveDocLink(REPO, '')).toBe(''); + }); + + // Without this, `[Rules](rules.md)` inside getting-started.md resolves + // against the site rendering it and 404s. + it('points a relative link back at the source repository', () => { + expect(resolveDocLink(REPO, 'rules.md')).toBe( + 'https://github.com/acme-guild/handbook/blob/HEAD/rules.md' + ); + expect(resolveDocLink(REPO, './rules.md')).toBe( + 'https://github.com/acme-guild/handbook/blob/HEAD/rules.md' + ); + expect(resolveDocLink(REPO, '/docs/rules.md')).toBe( + 'https://github.com/acme-guild/handbook/blob/HEAD/docs/rules.md' + ); + }); + + it('resolves against the containing document when one is given', () => { + expect(resolveDocLink(REPO, 'rules.md', {fromPath: 'handbook/getting-started.md'})).toBe( + 'https://github.com/acme-guild/handbook/blob/HEAD/handbook/rules.md' + ); + }); + + it('carries a fragment along untouched', () => { + expect(resolveDocLink(REPO, 'rules.md#no-griefing')).toBe( + 'https://github.com/acme-guild/handbook/blob/HEAD/rules.md#no-griefing' + ); + }); + + it('leaves a link that climbs out of the repository as written', () => { + expect(resolveDocLink(REPO, '../../etc/passwd', {fromPath: 'handbook/a.md'})).toBe('../../etc/passwd'); + }); +}); + +describe('isSelfContainedTarget', () => { + it('recognises the three shapes that need no rewriting', () => { + expect(isSelfContainedTarget('#a')).toBe(true); + expect(isSelfContainedTarget('//example.test')).toBe(true); + expect(isSelfContainedTarget('https://example.test')).toBe(true); + expect(isSelfContainedTarget('rules.md')).toBe(false); + expect(isSelfContainedTarget('/docs/rules.md')).toBe(false); + }); +}); diff --git a/js/tsconfig.build.json b/js/tsconfig.build.json new file mode 100644 index 0000000..959c851 --- /dev/null +++ b/js/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "declarationMap": true, + "sourceMap": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/js/tsconfig.json b/js/tsconfig.json new file mode 100644 index 0000000..9340949 --- /dev/null +++ b/js/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2020", + "lib": ["es2020", "dom"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "noEmit": true, + "types": [] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/js/vitest.config.ts b/js/vitest.config.ts new file mode 100644 index 0000000..02fa6f4 --- /dev/null +++ b/js/vitest.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + // Node, not jsdom: this package is server-side code that reads a token, + // and none of it touches a DOM. + environment: 'node', + include: ['test/**/*.test.ts'] + } +}); diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 0000000..f8d146f --- /dev/null +++ b/python/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/python/README.md b/python/README.md new file mode 100644 index 0000000..9c1b42b --- /dev/null +++ b/python/README.md @@ -0,0 +1,150 @@ +# github-docs + +Land documentation edits in a GitHub repository as **pull requests**, never as +direct pushes. + +Your staff app has a "save" button on a markdown editor. This turns that button +into a reviewable PR: commit to a per-file branch, open a pull request against +the default branch — or find the open one from the last save and add to it, so +repeated edits to the same page update one PR rather than piling up duplicates. + +Standard library `urllib` only. No `requests`, no dependency tree. + +This is the write half of [`kingdom-community/github-docs`](https://github.com/kingdom-community/github-docs). +The read half — fetching markdown for a website to render — is the npm package +[`@kingdom-community/github-docs`](https://github.com/kingdom-community/github-docs/tree/main/js). + +## Install + +```bash +pip install github-docs +``` + +Python 3.9 or newer. + +## Usage + +```python +import os + +from github_docs import GitHubDocsClient, GitHubDocsConfig, GitHubDocsError + +docs = GitHubDocsClient( + GitHubDocsConfig( + repo="acme-guild/handbook", + token=os.environ["DOCS_GITHUB_TOKEN"], + # Only these path roots may be listed or edited. Everything else in the + # repo -- CI config, infrastructure code -- is refused before a request + # is made. + allowed_roots=("handbook", "policies", "players"), + ) +) + +# What can be edited. +for doc in docs.list_documents(): + print(doc.path, doc.sha, doc.size) + +# Load one into the editor. +current = docs.get_file("handbook/rules.md") +print(current.content) + +# Save it. +try: + result = docs.save_file("handbook/rules.md", new_text, author="mod99") +except GitHubDocsError as err: + return render_error(str(err), status=err.status or 502) + +print(result.pr_url) # https://github.com/acme-guild/handbook/pull/42 +print(result.created) # False when it added to an existing open PR +``` + +## What a save actually does + +1. Look up the repository's default branch and its current tip SHA. +2. Ensure the per-file branch `docs-edit/` exists, branching it + off the default branch if it does not. +3. PUT the new content to that branch through the Contents API — one commit, + carrying the file's current SHA *on that branch* so the API updates rather + than rejecting the write as a conflicting create. +4. Reuse the open PR for that branch if there is one, else open a new one. + +Step 4 is what makes repeated saves *update*. The pull request is the review +mechanism, which is why there is no diff or version UI to build. + +The default branch is never written to. That is the whole point: a doc repo +whose history already goes through review for every change should not grow a +side door just because the edit arrived from a web form. + +## Configuration + +`GitHubDocsConfig` is a frozen dataclass. + +| Field | Default | What it does | +|---|---|---| +| `repo` | *(required)* | `owner/repo`. Validated on construction. | +| `token` | `""` | A GitHub personal access token. Needs `repo`, or fine-grained Contents + Pull-requests read/write on the one repository. | +| `allowed_roots` | `None` | Path roots this client may touch. `None` means the whole repository; an **empty tuple means nothing**, which is the safe reading of "not filled in yet". | +| `extensions` | `(".md",)` | Which files are listed and edited. | +| `branch_prefix` | `"docs-edit/"` | Prefix for the per-file branch. | +| `api_base` | `"https://api.github.com"` | For GitHub Enterprise. | +| `timeout` | `15.0` | Seconds. A save is interactive; it should fail visibly rather than hang a request thread. | +| `user_agent` | `"github-docs"` | Sent on every request. | +| `commit_message_template` | `"Update {path} (edited by {author})"` | `{path}` and `{author}` are substituted. | +| `pr_title_template` | `"Docs edit: {path}"` | " | +| `pr_body_template` | `"Edited by **{author}** ..."` | " | + +This package reads no environment variables of its own — a library that reads +`os.environ` is a library you cannot configure twice in one process. Read yours +in your app and pass the values in. + +Point `repo` at the repository's **current canonical name**. GitHub keeps a +renamed or transferred repository's old name working via a 301 redirect, but +`urllib` only auto-follows redirects for GET — the POST and PUT calls (branch +create, content commit, PR open) hard-fail with "Moved Permanently" against the +old name. + +## Errors + +Everything that stopped an edit from landing raises `GitHubDocsError`, which +carries `.status` — the upstream HTTP status when there was one — so a caller +can map a 404 to its own 404 and everything else to a 502 without parsing the +message. + +Unlike the read half of this pair, the write half *does* surface GitHub's own +error message: an operator staring at a failed save needs to know whether it was +a permissions problem or a merge conflict, and there is a human in the loop to +read it. The one thing that never travels with that message is the credential — +the token is stripped from any error text on its way into the exception, because +a control that depends on somebody else's behaviour is not a control. + +## API + +- `GitHubDocsConfig(...)` — configuration. +- `GitHubDocsClient(config)` + - `.list_documents() -> list[DocumentSummary]` + - `.get_file(path, ref=None) -> Document` + - `.save_file(path, content, author, message=None) -> SaveResult` + - `.is_managed(path) -> bool` + - `.branch_name_for(path) -> str` + - `.get_default_branch()`, `.get_ref_sha()`, `.create_ref()`, `.find_open_pr()` +- `Document`, `DocumentSummary`, `SaveResult` — frozen dataclasses. +- `GitHubDocsError` +- `slugify_path(path)` + +## Development + +```bash +python3 -m unittest discover -s tests -v +``` + +The tests mock `urllib.request.urlopen` with a small fake GitHub API keyed on +(method, url), so they need no network access and never touch a real repository. + +## License + +MIT. + +## Origins + +Extracted from the website and infrastructure stack behind a Minecraft +community server, generalised and released under MIT. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..808b4a8 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "github-docs" +version = "0.1.0" +description = "Land documentation edits in a GitHub repository as pull requests, never as direct pushes. Standard library only." +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +authors = [{name = "Daniel McCoy Stephenson", email = "dmccoystephenson@gmail.com"}] +keywords = ["github", "documentation", "docs-as-code", "markdown", "pull-request", "community"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Documentation", + "Topic :: Software Development :: Version Control :: Git", + "Typing :: Typed", +] +# Standard library only, deliberately: this drops into a stdlib-first service +# without dragging a dependency tree behind it. +dependencies = [] + +[project.urls] +Homepage = "https://github.com/kingdom-community/github-docs" +Repository = "https://github.com/kingdom-community/github-docs" +Issues = "https://github.com/kingdom-community/github-docs/issues" + +[tool.setuptools] +license-files = ["LICENSE"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +github_docs = ["py.typed"] diff --git a/python/src/github_docs/__init__.py b/python/src/github_docs/__init__.py new file mode 100644 index 0000000..d0b388b --- /dev/null +++ b/python/src/github_docs/__init__.py @@ -0,0 +1,30 @@ +"""github-docs -- land documentation edits in a GitHub repository as pull requests. + +The write half of a pair. Its sibling, ``@kingdom-community/github-docs`` on +npm, reads the same repository for rendering. Between them: documentation lives +as markdown in git, a website renders it, and edits arrive as reviewable pull +requests rather than as direct pushes. +""" + +from .client import ( + Document, + DocumentSummary, + GitHubDocsClient, + GitHubDocsConfig, + GitHubDocsError, + SaveResult, + slugify_path, +) + +__all__ = [ + "Document", + "DocumentSummary", + "GitHubDocsClient", + "GitHubDocsConfig", + "GitHubDocsError", + "SaveResult", + "slugify_path", + "__version__", +] + +__version__ = "0.1.0" diff --git a/python/src/github_docs/client.py b/python/src/github_docs/client.py new file mode 100644 index 0000000..e410717 --- /dev/null +++ b/python/src/github_docs/client.py @@ -0,0 +1,396 @@ +"""Land documentation edits in a GitHub repository as pull requests. + +Lists and edits markdown files under a configured set of folders, landing +every edit as a PR against the repository's default branch -- NEVER a direct +push to it. That is the whole point: a doc repo whose history already goes +through review for every change should not grow a side door just because the +edit arrived from a web form. + +Auth is a GitHub personal access token for a bot or service account, supplied +by the caller. It needs `repo`, or fine-grained Contents + Pull-requests +read/write on the one repository. + +stdlib ``urllib`` only -- no third-party HTTP dependency, so this drops into a +stdlib-first service without dragging a dependency tree behind it. + +Flow for a save (see :meth:`GitHubDocsClient.save_file`): + + 1. Look up the repository's default branch and its current tip SHA. + 2. Ensure a per-file branch ```` exists, branched off + the default branch if it does not yet. + 3. PUT the new file content to that branch via the Contents API (one commit). + 4. Reuse an existing open PR for that branch if one exists, else open a new + one. This is what makes repeated saves to the same file *update* rather + than pile up duplicate PRs: the PR itself is the review mechanism, so no + separate diff or version UI is needed. +""" + +from __future__ import annotations + +import base64 +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +__all__ = [ + "Document", + "DocumentSummary", + "GitHubDocsClient", + "GitHubDocsConfig", + "GitHubDocsError", + "SaveResult", + "slugify_path", +] + + +class GitHubDocsError(Exception): + """Anything that stopped an edit from landing. + + ``status`` is the upstream HTTP status when there was one, so a caller can + turn a 404 into its own 404 and everything else into a 502 without parsing + the message. + """ + + def __init__(self, message: str, status: Optional[int] = None) -> None: + super().__init__(message) + self.status = status + + +_SLUG_RE = re.compile(r"[^a-zA-Z0-9._-]+") +_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$") + + +def slugify_path(path: str) -> str: + """``handbook/the rules.md`` -> ``handbook-the-rules.md``. + + A branch name has to be stable for a given path -- that is what lets a + second save find the first save's open PR instead of opening another one -- + and it has to survive the characters a path may contain that a ref may not. + """ + return _SLUG_RE.sub("-", path).strip("-") + + +@dataclass(frozen=True) +class DocumentSummary: + """One markdown file, as it appears in a listing.""" + + path: str + sha: str + size: int = 0 + + +@dataclass(frozen=True) +class Document: + """One markdown file, with its contents decoded.""" + + path: str + content: str + sha: str + + +@dataclass(frozen=True) +class SaveResult: + """What a save produced. + + ``created`` is False when an existing open PR was reused rather than a new + one opened. + """ + + branch: str + pr_url: str + pr_number: int + created: bool + + +@dataclass(frozen=True) +class GitHubDocsConfig: + """Everything that varies between one community's doc repo and another's.""" + + #: ``owner/repo``. + #: + #: Point this at the repository's CURRENT canonical name. GitHub keeps a + #: renamed or transferred repository's old name working via a 301 redirect, + #: but ``urllib`` only auto-follows redirects for GET -- POST and PUT + #: (branch create, content commit, PR open) hard-fail with "Moved + #: Permanently" against the old name. + repo: str + + #: A personal access token for a bot or service account. + token: str = "" + + #: The path roots this client is willing to touch, e.g. + #: ``("handbook", "policies")``. + #: + #: ``None`` means the whole repository. An EMPTY tuple means nothing, which + #: is the safe reading of "the operator has not filled this in yet" -- not + #: the same thing as ``None``. Anything outside the roots is refused before + #: a request is made, so a form field cannot be talked into editing CI + #: configuration or infrastructure code that happens to share the repo. + allowed_roots: Optional[Tuple[str, ...]] = None + + #: File extensions this client lists and edits. + extensions: Tuple[str, ...] = (".md",) + + #: Prefix for the per-file branch. Must end in something that keeps these + #: refs out of the way of hand-made branches. + branch_prefix: str = "docs-edit/" + + #: Overridable for GitHub Enterprise. + api_base: str = "https://api.github.com" + + #: Seconds. A doc save is interactive; it should fail visibly rather than + #: hang a request thread. + timeout: float = 15.0 + + user_agent: str = "github-docs" + + #: ``{path}`` and ``{author}`` are substituted. + commit_message_template: str = "Update {path} (edited by {author})" + pr_title_template: str = "Docs edit: {path}" + pr_body_template: str = "Edited by **{author}** via the documentation editor.\n\nFile: `{path}`" + + def __post_init__(self) -> None: + repo = self.repo.strip().strip("/") + if not _REPO_RE.match(repo): + # The caller's own configuration, so quoting it back is safe and is + # the only way this is debuggable. + raise ValueError(f"github-docs: {self.repo!r} is not an 'owner/repo' repository slug") + object.__setattr__(self, "repo", repo) + if self.allowed_roots is not None: + object.__setattr__( + self, + "allowed_roots", + tuple(root.strip().strip("/") for root in self.allowed_roots if root.strip().strip("/")), + ) + + @property + def owner(self) -> str: + return self.repo.split("/", 1)[0] + + +def _is_safe_path(path: str) -> bool: + """A path this client is willing to send to GitHub. + + Deliberately narrow. Anything with an empty, ``.`` or ``..`` segment, a + leading slash, a backslash, a scheme or whitespace is not a path in a + repository, and is refused rather than escaped -- a path that needs + escaping is a typo or an attack, and neither should reach the network. + """ + if not path or path != path.strip() or path.startswith("/") or "\\" in path: + return False + segments = path.split("/") + return all(segment not in ("", ".", "..") for segment in segments) + + +class GitHubDocsClient: + """A doc-editing client for one repository.""" + + def __init__(self, config: GitHubDocsConfig) -> None: + self.config = config + + # -- HTTP --------------------------------------------------------------- + + def _redact(self, text: str) -> str: + """Remove the token from a string on its way into an exception. + + The write half of this library DOES surface GitHub's own error message, + unlike the read half, which surfaces nothing: an operator staring at a + failed save needs to know whether it was a permissions problem or a + merge conflict, and there is a human in the loop to read it. That makes + it worth being explicit that the one thing which never travels with + that message is the credential -- GitHub does not echo it back today, + but a proxy or a future API might, and a control that depends on + somebody else's behaviour is not a control. + """ + token = self.config.token.strip() + return text.replace(token, "***") if token else text + + def _request( + self, + method: str, + path: str, + body: Optional[Dict[str, Any]] = None, + allow_404: bool = False, + ) -> Tuple[int, Any]: + if not self.config.token: + raise GitHubDocsError( + "no GitHub token is configured -- document editing is disabled until a " + "personal access token for the documentation bot account is provisioned" + ) + url = path if path.startswith("http") else f"{self.config.api_base.rstrip('/')}{path}" + data = json.dumps(body).encode() if body is not None else None + headers = { + "Authorization": f"Bearer {self.config.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": self.config.user_agent, + } + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: + raw = resp.read() + parsed = json.loads(raw) if raw else {} + return resp.status, parsed + except urllib.error.HTTPError as e: + raw = e.read() + try: + parsed = json.loads(raw) if raw else {} + except json.JSONDecodeError: + parsed = {} + if e.code == 404 and allow_404: + return 404, parsed + message = parsed.get("message") if isinstance(parsed, dict) else None + raise GitHubDocsError( + self._redact(message or f"GitHub API returned HTTP {e.code}"), status=e.code + ) from e + except urllib.error.URLError as e: + raise GitHubDocsError(self._redact(f"could not reach GitHub: {e.reason}")) from e + + # -- Paths -------------------------------------------------------------- + + def is_managed(self, path: str) -> bool: + """Whether this client may read or write ``path``.""" + if not _is_safe_path(path): + return False + roots = self.config.allowed_roots + if roots is None: + return True + return any(path == root or path.startswith(root + "/") for root in roots) + + def _require_managed(self, path: str) -> None: + if not self.is_managed(path): + roots = self.config.allowed_roots + where = "the repository" if roots is None else f"the managed paths {roots}" + raise GitHubDocsError(f"{path!r} is outside {where}", status=400) + + def branch_name_for(self, path: str) -> str: + """The per-file branch a save to ``path`` commits to.""" + return f"{self.config.branch_prefix}{slugify_path(path)}" + + # -- Reads -------------------------------------------------------------- + + def get_default_branch(self) -> str: + _, repo_info = self._request("GET", f"/repos/{self.config.repo}") + return str(repo_info["default_branch"]) + + def get_ref_sha(self, branch: str, allow_404: bool = False) -> Optional[str]: + status, data = self._request( + "GET", + f"/repos/{self.config.repo}/git/ref/heads/{urllib.parse.quote(branch)}", + allow_404=allow_404, + ) + if status == 404: + return None + return str(data["object"]["sha"]) + + def create_ref(self, branch: str, sha: str) -> None: + self._request( + "POST", + f"/repos/{self.config.repo}/git/refs", + {"ref": f"refs/heads/{branch}", "sha": sha}, + ) + + def list_documents(self) -> List[DocumentSummary]: + """Every managed document, via the recursive git-trees API. + + One call covers the whole repository tree, instead of one Contents-API + call per directory. + """ + default_branch = self.get_default_branch() + _, tree = self._request( + "GET", f"/repos/{self.config.repo}/git/trees/{urllib.parse.quote(default_branch)}?recursive=1" + ) + files: List[DocumentSummary] = [] + for entry in tree.get("tree", []): + if entry.get("type") != "blob": + continue + path = entry["path"] + if path.endswith(self.config.extensions) and self.is_managed(path): + files.append(DocumentSummary(path=path, sha=entry["sha"], size=entry.get("size", 0))) + files.sort(key=lambda f: f.path) + return files + + def get_file(self, path: str, ref: Optional[str] = None) -> Document: + self._require_managed(path) + qs = f"?ref={urllib.parse.quote(ref)}" if ref else "" + _, data = self._request( + "GET", f"/repos/{self.config.repo}/contents/{urllib.parse.quote(path)}{qs}" + ) + if data.get("encoding") != "base64": + raise GitHubDocsError(f"unexpected content encoding {data.get('encoding')!r} for {path}") + content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") + return Document(path=path, content=content, sha=data["sha"]) + + def find_open_pr(self, branch: str) -> Optional[Dict[str, Any]]: + _, prs = self._request( + "GET", + f"/repos/{self.config.repo}/pulls" + f"?head={self.config.owner}:{urllib.parse.quote(branch)}&state=open", + ) + return prs[0] if prs else None + + # -- Writes ------------------------------------------------------------- + + def save_file( + self, + path: str, + content: str, + author: str, + message: Optional[str] = None, + ) -> SaveResult: + """Commit ``content`` to a per-file branch and open (or reuse) a PR. + + Never touches the default branch. Repeated saves to the same path land + as further commits on the same branch and the same open PR. + """ + self._require_managed(path) + + default_branch = self.get_default_branch() + branch = self.branch_name_for(path) + + branch_sha = self.get_ref_sha(branch, allow_404=True) + if branch_sha is None: + base_sha = self.get_ref_sha(default_branch) + if base_sha is None: + raise GitHubDocsError(f"default branch {default_branch!r} has no tip commit") + self.create_ref(branch, base_sha) + + # Current sha of the file ON THE BRANCH: the Contents API needs it to + # update rather than reject the write as a conflicting create. It may + # differ from the sha the editor loaded, if someone else saved to this + # branch in the meantime. + existing = self.get_file(path, ref=branch) + commit_message = message or self.config.commit_message_template.format(path=path, author=author) + body = { + "message": commit_message, + "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), + "sha": existing.sha, + "branch": branch, + } + self._request("PUT", f"/repos/{self.config.repo}/contents/{urllib.parse.quote(path)}", body) + + pr = self.find_open_pr(branch) + if pr: + return SaveResult( + branch=branch, pr_url=pr["html_url"], pr_number=pr["number"], created=False + ) + + _, pr_data = self._request( + "POST", + f"/repos/{self.config.repo}/pulls", + { + "title": self.config.pr_title_template.format(path=path, author=author), + "head": branch, + "base": default_branch, + "body": self.config.pr_body_template.format(path=path, author=author), + }, + ) + return SaveResult( + branch=branch, pr_url=pr_data["html_url"], pr_number=pr_data["number"], created=True + ) diff --git a/python/src/github_docs/py.typed b/python/src/github_docs/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 0000000..414d512 --- /dev/null +++ b/python/tests/test_client.py @@ -0,0 +1,392 @@ +"""Unit tests for the branch/PR-reuse logic. + +``urllib.request.urlopen`` is mocked with a small fake GitHub API keyed on +(method, url), so these tests need no network access and never touch a real +repository. + +Run: python3 -m unittest discover -s tests -v +""" + +import base64 +import json +import unittest +import urllib.error +import urllib.parse +from unittest import mock + +from github_docs import ( + Document, + GitHubDocsClient, + GitHubDocsConfig, + GitHubDocsError, + slugify_path, +) + +REPO = "acme-guild/handbook" +TOKEN = "ghp_a1b2c3d4e5f6g7h8i9j0_secretvalue" +ROOTS = ("handbook", "policies", "players") + + +def make_client(**overrides) -> GitHubDocsClient: + settings = {"repo": REPO, "token": TOKEN, "allowed_roots": ROOTS} + settings.update(overrides) + return GitHubDocsClient(GitHubDocsConfig(**settings)) + + +def _response(payload, status=200): + resp = mock.MagicMock() + resp.read.return_value = json.dumps(payload).encode() + resp.status = status + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + +def _http_error(code=404, message="Not Found"): + err = urllib.error.HTTPError(url="x", code=code, msg=message, hdrs=None, fp=None) + err.read = mock.MagicMock(return_value=json.dumps({"message": message}).encode()) + return err + + +class FakeGitHub: + """Routes urlopen(req) calls by (method, url-without-query) to a handler, + so tests read as "given this API state, calling save_file() does X" rather + than as a brittle ordered list of responses.""" + + def __init__(self, client: GitHubDocsClient, edited_path: str = "handbook/example.md"): + self.client = client + self.edited_path = edited_path + self.default_branch = "main" + self.branch_exists = False + self.branch_file_sha = "filesha-onbranch" + self.default_branch_sha = "sha-on-main" + self.open_prs = [] # dicts with html_url/number + self.created_refs = [] + self.put_calls = [] + self.created_prs = [] + self.requests = [] # (method, full_url, headers) + + def __call__(self, req, timeout=None): + method = req.get_method() + url = req.full_url.split("?", 1)[0] + self.requests.append((method, req.full_url, dict(req.headers))) + + if method == "GET" and url.endswith(f"/repos/{REPO}"): + return _response({"default_branch": self.default_branch}) + + if method == "GET" and "/git/ref/heads/" in url: + branch = urllib.parse.unquote(url.split("/git/ref/heads/")[1]) + if branch == self.default_branch: + return _response({"object": {"sha": self.default_branch_sha}}) + if branch == self.client.branch_name_for(self.edited_path) and self.branch_exists: + return _response({"object": {"sha": "branch-tip-sha"}}) + raise _http_error() + + if method == "GET" and "/git/trees/" in url: + return _response( + { + "tree": [ + {"type": "blob", "path": "handbook/b.md", "sha": "s2", "size": 20}, + {"type": "blob", "path": "handbook/a.md", "sha": "s1", "size": 10}, + {"type": "blob", "path": "handbook/logo.png", "sha": "s3", "size": 30}, + {"type": "blob", "path": "infrastructure/main.tf", "sha": "s4", "size": 40}, + {"type": "blob", "path": "README.md", "sha": "s5", "size": 50}, + {"type": "tree", "path": "handbook", "sha": "s6"}, + ] + } + ) + + if method == "POST" and url.endswith("/git/refs"): + body = json.loads(req.data) + self.created_refs.append(body) + self.branch_exists = True + return _response({"ref": body["ref"]}, status=201) + + if method == "GET" and "/contents/" in url: + return _response( + { + "content": base64.b64encode(b"old content").decode(), + "encoding": "base64", + "sha": self.branch_file_sha, + } + ) + + if method == "PUT" and "/contents/" in url: + self.put_calls.append(json.loads(req.data)) + return _response({"content": {"sha": "newsha"}}, status=200) + + if method == "GET" and url.endswith("/pulls"): + return _response(self.open_prs) + + if method == "POST" and url.endswith("/pulls"): + body = json.loads(req.data) + pr = {"html_url": f"https://github.com/{REPO}/pull/42", "number": 42, **body} + self.created_prs.append(pr) + return _response(pr, status=201) + + raise AssertionError(f"unexpected request: {method} {url}") + + +class TestConfig(unittest.TestCase): + def test_rejects_something_that_is_not_a_repository_slug(self): + for bad in ["handbook", "acme-guild/", "/handbook", "a/b/c", "https://github.com/a/b", ""]: + with self.subTest(bad): + with self.assertRaises(ValueError): + GitHubDocsConfig(repo=bad, token=TOKEN) + + def test_tidies_the_slashes_a_human_leaves_behind(self): + self.assertEqual(GitHubDocsConfig(repo=" /acme-guild/handbook/ ", token=TOKEN).repo, REPO) + + def test_owner_is_the_first_half(self): + self.assertEqual(GitHubDocsConfig(repo=REPO, token=TOKEN).owner, "acme-guild") + + +class TestNoTokenConfigured(unittest.TestCase): + def test_raises_a_clear_error_when_the_token_is_missing(self): + client = make_client(token="") + with self.assertRaises(GitHubDocsError) as ctx: + client.get_default_branch() + message = str(ctx.exception) + self.assertIn("token", message) + # An error that says only "401" leaves an operator guessing; this one + # names the missing thing and what it disables. + self.assertIn("document editing is disabled", message) + + +class TestManagedPaths(unittest.TestCase): + def setUp(self): + self.client = make_client() + + def test_managed_paths(self): + self.assertTrue(self.client.is_managed("handbook/getting-started.md")) + self.assertTrue(self.client.is_managed("policies/moderation.md")) + self.assertTrue(self.client.is_managed("players/rules.md")) + + def test_unmanaged_paths_rejected(self): + self.assertFalse(self.client.is_managed("infrastructure/terraform/main.tf")) + self.assertFalse(self.client.is_managed("plugins/README.md")) + self.assertFalse(self.client.is_managed("README.md")) + # A prefix match is not a path match. + self.assertFalse(self.client.is_managed("handbookish/x.md")) + + def test_traversal_is_refused_even_under_a_managed_root(self): + for path in [ + "handbook/../infrastructure/main.tf", + "handbook/./x.md", + "/handbook/x.md", + "handbook//x.md", + "handbook\\x.md", + " handbook/x.md", + "", + ]: + with self.subTest(path): + self.assertFalse(self.client.is_managed(path)) + + def test_none_means_the_whole_repository(self): + client = make_client(allowed_roots=None) + self.assertTrue(client.is_managed("anything/at/all.md")) + self.assertFalse(client.is_managed("../escape.md")) + + def test_empty_tuple_means_nothing_rather_than_everything(self): + client = make_client(allowed_roots=()) + self.assertFalse(client.is_managed("handbook/rules.md")) + + def test_get_file_rejects_an_unmanaged_path(self): + with self.assertRaises(GitHubDocsError): + self.client.get_file("infrastructure/terraform/main.tf") + + def test_save_file_rejects_an_unmanaged_path(self): + with self.assertRaises(GitHubDocsError): + self.client.save_file("plugins/README.md", "x", "someone") + + def test_the_gate_runs_before_the_request_not_after(self): + urlopen = mock.MagicMock() + with mock.patch("urllib.request.urlopen", urlopen): + with self.assertRaises(GitHubDocsError): + self.client.save_file("infrastructure/main.tf", "x", "someone") + urlopen.assert_not_called() + + +class TestBranchNaming(unittest.TestCase): + def setUp(self): + self.client = make_client() + + def test_slugifies_path_separators(self): + self.assertEqual( + self.client.branch_name_for("handbook/getting-started.md"), + "docs-edit/handbook-getting-started.md", + ) + + def test_stable_and_collision_free_for_distinct_paths(self): + a = self.client.branch_name_for("policies/moderation.md") + b = self.client.branch_name_for("policies/escalation.md") + self.assertNotEqual(a, b) + self.assertEqual(a, self.client.branch_name_for("policies/moderation.md")) + + def test_prefix_is_configurable(self): + client = make_client(branch_prefix="content/") + self.assertEqual(client.branch_name_for("handbook/a.md"), "content/handbook-a.md") + + def test_slugify_drops_the_characters_a_ref_may_not_carry(self): + self.assertEqual(slugify_path("handbook/the rules!.md"), "handbook-the-rules-.md") + + +class TestListDocuments(unittest.TestCase): + def test_lists_only_managed_markdown_sorted(self): + client = make_client() + fake = FakeGitHub(client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + files = client.list_documents() + self.assertEqual([f.path for f in files], ["handbook/a.md", "handbook/b.md"]) + self.assertEqual(files[0].sha, "s1") + self.assertEqual(files[0].size, 10) + + def test_extensions_are_configurable(self): + client = make_client(extensions=(".md", ".png")) + fake = FakeGitHub(client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + files = client.list_documents() + self.assertEqual([f.path for f in files], ["handbook/a.md", "handbook/b.md", "handbook/logo.png"]) + + +class TestGetFile(unittest.TestCase): + def test_decodes_the_base64_envelope(self): + client = make_client() + fake = FakeGitHub(client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + doc = client.get_file("handbook/example.md") + self.assertEqual(doc, Document(path="handbook/example.md", content="old content", sha="filesha-onbranch")) + + +class TestSaveFileCreatesBranchAndPr(unittest.TestCase): + def setUp(self): + self.client = make_client() + + def test_creates_branch_and_opens_pr_when_none_exists(self): + fake = FakeGitHub(self.client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + result = self.client.save_file("handbook/example.md", "new content", "someone") + + self.assertTrue(fake.created_refs, "expected a new branch ref to be created") + self.assertEqual( + fake.created_refs[0]["ref"], "refs/heads/docs-edit/handbook-example.md" + ) + self.assertEqual(fake.created_refs[0]["sha"], "sha-on-main") + self.assertEqual(len(fake.put_calls), 1) + self.assertEqual(base64.b64decode(fake.put_calls[0]["content"]).decode(), "new content") + self.assertTrue(result.created) + self.assertEqual(result.pr_url, f"https://github.com/{REPO}/pull/42") + + def test_reuses_existing_open_pr_instead_of_opening_a_duplicate(self): + fake = FakeGitHub(self.client) + fake.branch_exists = True + fake.open_prs = [{"html_url": f"https://github.com/{REPO}/pull/7", "number": 7}] + with mock.patch("urllib.request.urlopen", side_effect=fake): + result = self.client.save_file("handbook/example.md", "second edit", "someone") + + self.assertFalse(result.created) + self.assertEqual(result.pr_number, 7) + self.assertEqual(fake.created_prs, []) + self.assertEqual(fake.created_refs, [], "the branch already existed; it must not be recreated") + + def test_commit_message_includes_author(self): + fake = FakeGitHub(self.client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + self.client.save_file("handbook/example.md", "content", "mod99") + self.assertIn("mod99", fake.put_calls[0]["message"]) + + def test_an_explicit_message_wins(self): + fake = FakeGitHub(self.client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + self.client.save_file("handbook/example.md", "c", "mod99", message="Fix a typo") + self.assertEqual(fake.put_calls[0]["message"], "Fix a typo") + + def test_templates_are_configurable(self): + client = make_client( + commit_message_template="docs: {path}", + pr_title_template="[docs] {path}", + pr_body_template="{author} edited {path}", + ) + fake = FakeGitHub(client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + client.save_file("handbook/example.md", "c", "mod99") + self.assertEqual(fake.put_calls[0]["message"], "docs: handbook/example.md") + self.assertEqual(fake.created_prs[0]["title"], "[docs] handbook/example.md") + self.assertEqual(fake.created_prs[0]["body"], "mod99 edited handbook/example.md") + + # The whole reason this library exists rather than a two-line push. + def test_never_writes_to_the_default_branch(self): + fake = FakeGitHub(self.client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + self.client.save_file("handbook/example.md", "content", "someone") + + branch = self.client.branch_name_for("handbook/example.md") + self.assertEqual(fake.put_calls[0]["branch"], branch) + self.assertNotEqual(fake.put_calls[0]["branch"], fake.default_branch) + self.assertEqual(fake.created_prs[0]["base"], "main") + self.assertEqual(fake.created_prs[0]["head"], branch) + + def test_updates_rather_than_recreates_by_sending_the_files_current_sha(self): + fake = FakeGitHub(self.client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + self.client.save_file("handbook/example.md", "content", "someone") + self.assertEqual(fake.put_calls[0]["sha"], "filesha-onbranch") + + +class TestCredentialHandling(unittest.TestCase): + def setUp(self): + self.client = make_client() + + def test_token_travels_in_a_header_and_never_in_a_url(self): + fake = FakeGitHub(self.client) + with mock.patch("urllib.request.urlopen", side_effect=fake): + self.client.save_file("handbook/example.md", "content", "someone") + + self.assertTrue(fake.requests) + for method, url, headers in fake.requests: + self.assertNotIn(TOKEN, url, f"{method} {url}") + # urllib title-cases header names on the Request object. + self.assertEqual(headers.get("Authorization"), f"Bearer {TOKEN}") + + def test_an_upstream_error_that_echoes_the_token_is_redacted(self): + # GitHub does not echo the credential back, but a proxy or a future API + # might, and an error message tends to end up in a log. + def raise_leaky(req, timeout=None): + raise _http_error(code=401, message=f"Bad credentials: {TOKEN}") + + with mock.patch("urllib.request.urlopen", side_effect=raise_leaky): + with self.assertRaises(GitHubDocsError) as ctx: + self.client.get_default_branch() + + self.assertNotIn(TOKEN, str(ctx.exception)) + self.assertIn("***", str(ctx.exception)) + self.assertEqual(ctx.exception.status, 401) + + +class TestErrorSurface(unittest.TestCase): + def setUp(self): + self.client = make_client() + + def test_surfaces_the_upstream_status_so_a_caller_can_map_it(self): + with mock.patch("urllib.request.urlopen", side_effect=_http_error(code=404, message="Not Found")): + with self.assertRaises(GitHubDocsError) as ctx: + self.client.get_default_branch() + self.assertEqual(ctx.exception.status, 404) + self.assertIn("Not Found", str(ctx.exception)) + + def test_allow_404_turns_a_missing_ref_into_none_rather_than_an_error(self): + with mock.patch("urllib.request.urlopen", side_effect=_http_error()): + self.assertIsNone(self.client.get_ref_sha("no-such-branch", allow_404=True)) + + def test_a_network_failure_becomes_a_readable_error(self): + with mock.patch( + "urllib.request.urlopen", side_effect=urllib.error.URLError("Name or service not known") + ): + with self.assertRaises(GitHubDocsError) as ctx: + self.client.get_default_branch() + self.assertIn("could not reach GitHub", str(ctx.exception)) + self.assertIsNone(ctx.exception.status) + + +if __name__ == "__main__": + unittest.main()