From 6f28dfb4e1ded3c5272a8a94c1c97cf6cd917986 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:17:59 +0100 Subject: [PATCH 01/27] feat: start the docs site with the brand theme and site checks Gatepost needs one site for the guides, the API reference, the spec and a playground. Astro and Starlight build it as static files for GitHub Pages. The theme uses the brand colours, with text colours that reach 4.5:1 in both themes. Each page carries the line that NIPOST did not make the site, and the site tests fail when a page lacks it or loads anything from another website. Signed-off-by: MobileMage --- .editorconfig | 15 + .github/workflows/ci.yml | 84 + .github/workflows/pages.yml | 49 + .github/workflows/pull-request.yml | 42 + .gitignore | 13 + .gitmodules | 9 + .prettierignore | 10 + .prettierrc.json | 5 + AGENTS.md | 32 + CLAUDE.md | 1 + CODING_STANDARDS.md | 8 + CONTEXT.md | 174 + LICENSE | 202 + LICENSES/Apache-2.0.txt | 73 + NOTICE | 5 + README.md | 61 + REUSE.toml | 16 + astro.config.mjs | 49 + commitlint.config.js | 11 + eslint.config.js | 64 + js | 1 + package.json | 51 + php | 1 + pnpm-lock.yaml | 5934 +++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + public/apple-touch-icon-180.png | Bin 0 -> 614 bytes public/favicon-32.png | Bin 0 -> 180 bytes public/favicon.svg | 14 + renovate.json | 50 + scripts/check-licences.ts | 64 + scripts/check-prose.ts | 124 + spec | 1 + src/assets/gatepost-lockup-dark.svg | 60 + src/assets/gatepost-lockup.svg | 60 + src/components/Footer.astro | 19 + src/content.config.ts | 9 + src/content/docs/index.mdx | 23 + src/content/docs/privacy.md | 14 + src/styles/theme.css | 57 + test/site/built-site.ts | 37 + test/site/pages.test.ts | 57 + test/unit/check-licences.test.ts | 29 + test/unit/check-prose.test.ts | 49 + tsconfig.json | 12 + vitest.config.ts | 13 + 45 files changed, 7604 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/pull-request.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CODING_STANDARDS.md create mode 100644 CONTEXT.md create mode 100644 LICENSE create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 NOTICE create mode 100644 README.md create mode 100644 REUSE.toml create mode 100644 astro.config.mjs create mode 100644 commitlint.config.js create mode 100644 eslint.config.js create mode 160000 js create mode 100644 package.json create mode 160000 php create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 public/apple-touch-icon-180.png create mode 100644 public/favicon-32.png create mode 100644 public/favicon.svg create mode 100644 renovate.json create mode 100644 scripts/check-licences.ts create mode 100644 scripts/check-prose.ts create mode 160000 spec create mode 100644 src/assets/gatepost-lockup-dark.svg create mode 100644 src/assets/gatepost-lockup.svg create mode 100644 src/components/Footer.astro create mode 100644 src/content.config.ts create mode 100644 src/content/docs/index.mdx create mode 100644 src/content/docs/privacy.md create mode 100644 src/styles/theme.css create mode 100644 test/site/built-site.ts create mode 100644 test/site/pages.test.ts create mode 100644 test/unit/check-licences.test.ts create mode 100644 test/unit/check-prose.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5220e58 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 100 + +[*.php] +indent_size = 4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..abaac51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The js submodule holds its own spec submodule. + submodules: recursive + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + # The oldest Python that the scripts in the spec repo support. + python-version: '3.11' + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + # pnpm check starts reuse and zizmor with uvx. No lock file keys a cache. + enable-cache: false + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm check + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # The built site, so a reviewer can open the pages of a pull request. + name: site + path: dist + retention-days: 7 + + commits: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The checks read each commit from the base to the head. + fetch-depth: 0 + submodules: true + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm exec commitlint --from "$BASE" --to "$HEAD" + - name: Check each commit message for agent tells + run: | + for sha in $(git rev-list "$BASE..$HEAD"); do + git log -1 --format=%B "$sha" > "$RUNNER_TEMP/message" + python3 spec/scripts/check-tells --commit-msg "$RUNNER_TEMP/message" + done + + secrets: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # gitleaks must read the whole history. + fetch-depth: 0 + persist-credentials: false + - run: | + image=ghcr.io/gitleaks/gitleaks # v8.30.1 + digest=sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + docker run --rm -v "$PWD:/repo" "$image@$digest" git /repo diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..9caec3d --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: pages + +# Each push to main publishes the site on GitHub Pages. The ci workflow checks the same commit. +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm build + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..f43adf0 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: pull request + +# A squash merge puts the title and the body of the pull request on main as the commit message. +# An edit of the title or the body does not push a commit, so it must start this job again. +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: pull-request-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + squash-message: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: corepack enable + - run: pnpm install --frozen-lockfile + # The title and the body come through env, so that no text of the pull request ends up + # inside the script. + - name: Check the message that the squash merge puts on main + env: + TITLE: ${{ github.event.pull_request.title }} + NUMBER: ${{ github.event.pull_request.number }} + BODY: ${{ github.event.pull_request.body }} + run: | + printf '%s (#%s)\n\n%s\n' "$TITLE" "$NUMBER" "$BODY" > "$RUNNER_TEMP/message" + python3 spec/scripts/check-tells --squash-msg "$RUNNER_TEMP/message" --no-scope + pnpm exec commitlint --edit "$RUNNER_TEMP/message" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2477554 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +node_modules/ +dist/ +temp/ +coverage/ +.astro/ +test-results/ +playwright-report/ +# What the build writes from the spec, the doc comments and the mock gateway. +src/content/docs/spec/ +src/content/docs/reference/ +src/generated/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1385bc8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "spec"] + path = spec + url = https://github.com/gatepost-dev/spec +[submodule "js"] + path = js + url = https://github.com/gatepost-dev/js +[submodule "php"] + path = php + url = https://github.com/gatepost-dev/php diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4ac74b1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +pnpm-lock.yaml +spec +js +php +dist +temp +coverage +.astro +src/content/docs/spec +src/content/docs/reference diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..534bd00 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 100, + "plugins": ["prettier-plugin-astro"] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aec383f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +This repo is part of Gatepost, unofficial open-source developer tools for Nigeria's National Digital Postcode. This repo holds the docs site: the guides, the API reference, the spec pages and the playground. It reads the SDKs and the spec from the submodules `js`, `php` and `spec`. + +## Read first + +- `CODING_STANDARDS.md` and the two files it names hold the rules. Cite rule IDs, such as `API-3`, in reviews and commit bodies. +- `CONTEXT.md` is the glossary. Name things with its terms in code, tests, docs and commits. +- `docs/adr/` holds the decisions. When a change contradicts an ADR, say so in the pull request. +- `spec/` holds the grammar, with its Interface section, and the vectors. The spec wins over code and over these docs. + +## Rules that no config file shows + +- If the task needs a public symbol that the Interface section of `spec/grammar.md` does not list, add nothing, not even an internal helper. Stop, and say that a spec change must come first (API-1, CS-8). +- Call only the documented NIPOST gateway endpoints. +- Commit only NIPOST's published test codes and synthetic values, such as `FC-01-Z99-ZZ-01`. +- Sign off each commit with `git commit -s`. The person who opens the pull request is the only author. Leave out co-author lines for AI tools. +- Change `main` only through pull requests. Keep the history of `main` and of other people's branches as it is: never force-push to them. +- If a commit lacks its sign-off, run `git rebase --signoff` with the base branch. Then push to your own branch with `--force-with-lease`. +- Send every call to the gateway through the client. The client limits how many calls run at once. +- Write prose in plain British English, with short sentences in the active voice. +- Some agent tools change a typed backslash-u escape, such as the one for an en dash, into the character itself. Build such characters with code, for example `chr(0x2013)` in Python or `String.fromCodePoint(0x2013)` in TypeScript. In data and prose, write `U+2013`. +- Load nothing from another website: no fonts, scripts, styles, images or analytics. The site tests fail on each one. + +## Done means all of these + +1. A failing test came first, and it passes now. +2. `pnpm check` passes. It runs the formatter, linters, tests, every code example, the accessibility checks, `check-tells`, REUSE and zizmor. +3. Each code example on a page runs in CI and shows the result that it gets. +4. A page that describes the public interface matches the spec and the SDK version in the submodules. +5. Each new domain term is in `CONTEXT.md`. +6. The diff touches only the lines that the task needs. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md new file mode 100644 index 0000000..ead5e31 --- /dev/null +++ b/CODING_STANDARDS.md @@ -0,0 +1,8 @@ +# Coding standards + +This repo follows two files. Both are part of its standard: + +1. `spec/standards/CODING_STANDARDS.md` holds the rules for every Gatepost repo. +2. `spec/standards/languages/typescript.md` holds the rules for TypeScript. + +Rules for this repo only: the site is not a package, so it has no change files, no changelog and no API report. Its commits use no scope, as in the spec repo (GIT-1). diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..fcc8182 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,174 @@ +# Gatepost + +Unofficial, open-source developer tools for Nigeria's National Digital Postcode. This glossary fixes one name for each concept, in every language and every repo. + +## Language + +### The postcode + +**Postcode**: +The 11-character code that NIPOST gives to one addressable building or location. +_Avoid_: digital address, address code, zip code, postal code + +**Legacy postcode**: +An old 6-digit NIPOST postcode. It names an area, not a building. +_Avoid_: old code, zip + +**Segment**: +One of the five parts of a postcode: state, LGA, district, area and unit. +_Avoid_: part, field, component, chunk + +**State**: +The first segment. Two letters that name one of the 36 states or the Federal Capital Territory. +_Avoid_: region, province + +**LGA**: +The second segment. Two digits that name a local government area within a state. +_Avoid_: council, local government, municipality + +**District**: +The third segment. Three characters that name a district within an LGA. +_Avoid_: sorting code, sector + +**Area**: +The fourth segment. Two letters that name the smallest polygon in the system. An area holds at most 99 units. +_Avoid_: zone, block, neighbourhood + +**Unit**: +The fifth segment. Two digits that name one building or delivery point within an area. +_Avoid_: building number, house number, delivery unit + +**Partial postcode**: +A postcode that stops after the state, LGA, district or area segment. +_Avoid_: prefix, incomplete code +Exception: `prefix` is the name of the first parameter of `contains`. + +**Precision**: +The most specific segment that a postcode or a result contains. +_Avoid_: level, granularity, resolution + +### Forms of a postcode + +**Compact form**: +The postcode with no separators, for example `EK01A03FK01`. +_Avoid_: raw, plain + +**Canonical form**: +The postcode with hyphens between segments, for example `EK-01-A03-FK-01`. +_Avoid_: formatted, standard form + +**Display form**: +The postcode with spaces between segments, for example `EK 01 A03 FK 01`. +_Avoid_: pretty, human form + +**Normalise**: +To change input in three steps, in this order: apply NFKC, remove the separators, and change the ASCII letters to upper case. `grammar.md` defines the steps. +_Avoid_: sanitise, canonicalise + +**Code point**: +One Unicode code point. It is not a UTF-16 unit, a byte or a grapheme cluster. For example, U+1D404 is one code point and two UTF-16 units. +_Avoid_: character (when you count input), char + +**Input limit**: +The most code points that `parse` and `isLegacy` read. It is `maxInputCodePoints` in `data/format.json`. Over the limit, `parse` gives `bad_length` and `isLegacy` gives false. Both functions apply the limit before they normalise. +_Avoid_: max length, length limit, size limit + +**Suggestion**: +The canonical form that `parse` offers with `unknown_state` or `bad_segment`, when a fix of look-alike characters makes the code parse. It is a hint, never a success. The word means this hint from `parse`. It does not mean the suggestions that NIPOST's autocomplete API returns. +_Avoid_: correction, autocorrect + +**Parse result**: +What `parse` returns: a postcode, or a parse error. The Interface section of `grammar.md` defines its fields. +_Avoid_: outcome, response + +**Parse error**: +The reason that `parse` rejected its input. It has a code, the failing segment and a suggestion. It is a value that `parse` returns, not an exception. +_Avoid_: validation error, exception + +### NIPOST's API + +**Gateway**: +NIPOST's public API at `api.postcode.gov.ng`. +_Avoid_: server, backend, endpoint (for the whole API) + +**Lookup**: +A request to the gateway for the facts about one postcode. +_Avoid_: query, fetch, resolve + +**Lookup level**: +How much data a lookup returns, from 1 for validity only to 5 for point geometry. +_Avoid_: tier, access level, depth + +**Reverse geocode**: +A request for the nearest unit to a coordinate. +_Avoid_: reverse lookup, locate + +**Accuracy**: +The radius in metres within which a GPS fix is likely correct. +_Avoid_: precision, error margin + +**Secret key**: +An API key for servers. It starts with `nipost_test_` or `nipost_live_`. +_Avoid_: private key, API secret + +**Publishable key**: +An API key that can ship inside an app or a web page. It starts with `nipost_pk_`. +_Avoid_: public key, client key + +**Widget**: +NIPOST's own postcode picker. Gatepost does not build widgets. +_Avoid_: using this word for a Gatepost component + +### Gatepost + +**Spec**: +The `spec` repo. It holds the grammar, the client contract, the data, the vectors, the fixtures, the completed OpenAPI file and the standards. +_Avoid_: schema, contract (for the repo) + +**Contract scenario**: +One shared test case in `spec/contract/`. It names the calls, the mock server's responses and the outcome that every client must reach. +_Avoid_: contract test (for the file), vector, fixture + +**Attempt**: +One request that a client sends for one call. A retry is the second or third attempt. +_Avoid_: try, request (when you count them) + +**Evidence**: +How Gatepost knows the shape of a gateway response: `observed` in its own calls, `documented` by NIPOST only, or `assumed` by the mock server. +_Avoid_: source, confidence, proof + +**Vector**: +One shared test case in `spec/vectors/` that every SDK must pass. +_Avoid_: fixture, example, golden file + +**Fixture**: +Synthetic data that the mock server returns. +_Avoid_: vector, stub, sample + +**Mock server**: +The local server that imitates the gateway with fixtures. +_Avoid_: fake API, stub server, sandbox + +**SDK**: +The core and the client for one language. +_Avoid_: library (for the pair), wrapper, binding + +**Core**: +The part of an SDK that parses and formats postcodes without network access. +_Avoid_: utils, base, common + +**Client**: +The part of an SDK that calls the gateway. +_Avoid_: API wrapper, service, fetcher + +**Field**: +A Gatepost UI component in which a user enters or picks a postcode. +_Avoid_: input, widget, picker + +**Confirm**: +To show the user what a postcode points to before the app stores it. +_Avoid_: verify, validate (for this step) + +**Tell**: +A habit that makes code look machine-written, such as very long lines. +_Avoid_: smell (smells are design problems), anti-pattern diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..137069b --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..bc94510 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +Gatepost +Copyright 2026 The Gatepost authors + +This product is unofficial. The Nigerian Postal Service (NIPOST) did not make it and does not endorse it. +Gatepost uses the name NIPOST only to describe compatibility with NIPOST's National Digital Postcode. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0acdda8 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ + + + gatepost + + +# Gatepost docs + +The docs site of Gatepost: guides for each SDK, the API reference, the spec and a playground. + +> Unofficial. Not made or endorsed by NIPOST. + +[![CI](https://github.com/gatepost-dev/docs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gatepost-dev/docs/actions/workflows/ci.yml) +[![Licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](LICENSE) + +## Install + +```sh +git clone --recurse-submodules https://github.com/gatepost-dev/docs +cd docs +pnpm install +``` + +## Quickstart + +```sh +pnpm dev +``` + +Astro serves the site at `http://localhost:4321/docs/`. The scripts turn off Astro's usage data, so the build sends nothing. + +## What it does + +- Builds a static site with Astro and Starlight, for GitHub Pages. +- Fails when a page loads anything from another website, or when a page lacks the line that says that NIPOST did not make the site. + +## Requirements + +| Requirement | Version | +| ----------------------------------- | ----------------------------------- | +| Node | 24.15 or later | +| pnpm | 12, through Corepack | +| Python, for `check-tells` and REUSE | 3.11 or later, with uv | +| Gatepost spec | the version in the `spec` submodule | + +`pnpm check` runs every check that CI runs. + +## Docs + +The site itself is the docs. CI uploads the built site of each pull request as an artifact. + +## Support + +Ask questions and report problems with a page in GitHub Issues. Report security problems privately, as [`SECURITY.md`](https://github.com/gatepost-dev/.github/blob/main/SECURITY.md) describes. + +## Contributing + +Read [`CONTRIBUTING.md`](https://github.com/gatepost-dev/.github/blob/main/CONTRIBUTING.md) before you open a pull request. + +## Licence + +Apache-2.0. See `LICENSE` and `NOTICE`. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..66a6efe --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,16 @@ +version = 1 + +[[annotations]] +path = [ + "**/*.md", "**/*.mdx", "**/*.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".prettierignore", + "**/NOTICE", "CLAUDE.md", ".gitmodules", "public/*.png", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 The Gatepost authors" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = ["src/assets/*.svg", "public/favicon.svg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 The Gatepost authors" +SPDX-License-Identifier = "Apache-2.0" diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..f3e2fb8 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import starlight from '@astrojs/starlight'; +import { defineConfig } from 'astro/config'; + +// GitHub Pages serves the org's project site under the repo name. A custom domain changes the +// site and sets the base to '/'. +const site = 'https://gatepost-dev.github.io'; +const base = '/docs'; + +export default defineConfig({ + site, + base, + trailingSlash: 'always', + integrations: [ + starlight({ + title: 'Gatepost', + description: "Unofficial developer tools for Nigeria's digital postcode.", + // The title stays in the page for screen readers, so the logo needs no alt text of its own. + logo: { + light: './src/assets/gatepost-lockup.svg', + dark: './src/assets/gatepost-lockup-dark.svg', + replacesTitle: true, + }, + favicon: '/favicon.svg', + head: [ + { tag: 'link', attrs: { rel: 'icon', href: `${base}/favicon-32.png`, sizes: '32x32' } }, + { + tag: 'link', + attrs: { rel: 'apple-touch-icon', href: `${base}/apple-touch-icon-180.png` }, + }, + ], + locales: { root: { label: 'English', lang: 'en-GB' } }, + social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/gatepost-dev' }], + editLink: { baseUrl: 'https://github.com/gatepost-dev/docs/edit/main/' }, + customCss: [ + '@fontsource/overpass/400.css', + '@fontsource/overpass/600.css', + './src/styles/theme.css', + ], + components: { Footer: './src/components/Footer.astro' }, + credits: false, + // A code block that scrolls sideways needs a focus stop for keyboard users. Wrapped lines + // need none, and they fit a phone screen. + expressiveCode: { defaultProps: { wrap: true } }, + sidebar: [{ label: 'Project', items: ['privacy'] }], + }), + ], +}); diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..4fca655 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The site is not a package, so its commits use no scope, as in the spec repo (GIT-1). +export default { + extends: ['@commitlint/config-conventional'], + rules: { + 'header-max-length': [2, 'always', 72], + 'scope-empty': [2, 'always'], + 'signed-off-by': [2, 'always', 'Signed-off-by:'], + }, +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f1456d1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import eslint from '@eslint/js'; +import comments from '@eslint-community/eslint-plugin-eslint-comments/configs'; +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig( + { + ignores: [ + 'dist/**', + 'temp/**', + 'coverage/**', + '.astro/**', + 'spec/**', + 'js/**', + 'php/**', + 'test-results/**', + 'playwright-report/**', + ], + }, + eslint.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + comments.recommended, + { + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + rules: { + '@eslint-community/eslint-comments/require-description': 'error', + 'max-depth': ['error', 3], + complexity: ['error', 10], + 'max-params': ['error', 4], + 'no-empty': ['error', { allowEmptyCatch: false }], + 'no-nested-ternary': 'error', + '@typescript-eslint/require-await': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'error', + 'no-restricted-syntax': ['error', 'TSEnumDeclaration', 'TSModuleDeclaration'], + 'no-restricted-exports': [ + 'error', + { + restrictDefaultExports: { + direct: true, + named: true, + defaultFrom: true, + namedFrom: true, + namespaceFrom: true, + }, + }, + ], + }, + }, + { + // Code that runs in the reader's browser writes nothing to the console (TS-12). + files: ['src/**/*.ts'], + rules: { 'no-console': 'error' }, + }, + { + files: ['**/*.config.ts', '**/*.js', '**/*.mjs'], + extends: [tseslint.configs.disableTypeChecked], + rules: { 'no-restricted-exports': 'off' }, + }, +); diff --git a/js b/js new file mode 160000 index 0000000..3ba7fc4 --- /dev/null +++ b/js @@ -0,0 +1 @@ +Subproject commit 3ba7fc43c4f1c15685987e88ce20cd190f95065d diff --git a/package.json b/package.json new file mode 100644 index 0000000..819006f --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "gatepost-docs", + "private": true, + "type": "module", + "license": "Apache-2.0", + "packageManager": "pnpm@12.8.1", + "engines": { + "node": ">=24.15.0" + }, + "scripts": { + "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev", + "build": "ASTRO_TELEMETRY_DISABLED=1 astro build", + "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", + "check": "run-s check:source check:site check:policy", + "check:source": "run-s format:check lint typecheck test", + "check:site": "run-s build test:site", + "check:policy": "run-s tells prose context:check licences reuse:check workflows:check", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint . --max-warnings 0", + "typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check", + "test": "vitest run --project unit", + "test:site": "vitest run --project site", + "tells": "python3 spec/scripts/check-tells", + "prose": "node scripts/check-prose.ts", + "context:check": "cmp CONTEXT.md spec/CONTEXT.md", + "licences": "node scripts/check-licences.ts", + "reuse:check": "uvx --with charset-normalizer==3.5.2 reuse@6.2.0 lint", + "workflows:check": "uvx zizmor@1.30.1 --offline .github/workflows" + }, + "dependencies": { + "@astrojs/starlight": "^0.42.5", + "@fontsource/overpass": "^5.3.0", + "astro": "^7.3.5" + }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@commitlint/cli": "^21.2.3", + "@commitlint/config-conventional": "^21.2.3", + "@eslint-community/eslint-plugin-eslint-comments": "^4.8.1", + "@eslint/js": "^10.0.1", + "@types/node": "^24.19.1", + "eslint": "^10.11.0", + "npm-run-all2": "^9.0.3", + "prettier": "^3.9.9", + "prettier-plugin-astro": "^1.1.0", + "typescript": "6.0.3", + "typescript-eslint": "^8.71.0", + "vitest": "^5.0.3" + } +} diff --git a/php b/php new file mode 160000 index 0000000..8025240 --- /dev/null +++ b/php @@ -0,0 +1 @@ +Subproject commit 8025240109e6f49b7e34e869789b60b71eca086f diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c80a3cf --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5934 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.8.1 + version: 12.8.1 + +packages: + + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} + cpu: [x64] + os: [win32] + + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.8.1': + optional: true + + '@pnpm/exe.android-x64@12.8.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.8.1': + optional: true + + '@pnpm/exe.darwin-x64@12.8.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-arm64@12.8.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.8.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.8.1': + optional: true + + '@pnpm/exe.linux-s390x@12.8.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.8.1': + optional: true + + '@pnpm/exe.linux-x64@12.8.1': + optional: true + + '@pnpm/exe.win32-arm64@12.8.1': + optional: true + + '@pnpm/exe.win32-x64@12.8.1': + optional: true + + pnpm@12.8.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@astrojs/starlight': + specifier: ^0.42.5 + version: 0.42.5(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typescript@6.0.3) + '@fontsource/overpass': + specifier: ^5.3.0 + version: 5.3.0 + astro: + specifier: ^7.3.5 + version: 7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1) + devDependencies: + '@astrojs/check': + specifier: ^0.9.10 + version: 0.9.10(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9))(prettier@3.9.9)(typescript@6.0.3) + '@commitlint/cli': + specifier: ^21.2.3 + version: 21.2.3(@types/node@24.19.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) + '@commitlint/config-conventional': + specifier: ^21.2.3 + version: 21.2.3 + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.8.1 + version: 4.8.1(eslint@10.11.0(jiti@2.6.1)) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.11.0(jiti@2.6.1)) + '@types/node': + specifier: ^24.19.1 + version: 24.19.1 + eslint: + specifier: ^10.11.0 + version: 10.11.0(jiti@2.6.1) + npm-run-all2: + specifier: ^9.0.3 + version: 9.0.3 + prettier: + specifier: ^3.9.9 + version: 3.9.9 + prettier-plugin-astro: + specifier: ^1.1.0 + version: 1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9) + typescript: + specifier: 6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.71.0 + version: 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + vitest: + specifier: ^5.0.3 + version: 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + +packages: + + '@astrojs/astro2tsx@0.1.2': + resolution: {integrity: sha512-Uj/M9zbrhu5cIZEvWAGTbQJRKSxXWJ/E59BZwt7F+chrKhbfttW+9rkS/l+1QxoJP1KU3UnpM62M7yszfAI0WA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/check@0.9.10': + resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler-binding-android-arm64@0.4.1': + resolution: {integrity: sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-android-arm64@0.5.1': + resolution: {integrity: sha512-nMb2nMIJtcWLNH+fV75Enmi+jp4tIOYOfsv4XtpgdOWkqc9P4uSSdK5u1xb340AzrXo5V18SxLl/nc3vcqtamQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + resolution: {integrity: sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-arm64@0.5.1': + resolution: {integrity: sha512-5k4+nZ/WRt+dcVow4z7rBuI7iv9A58h/uQ91YJe1PrTivbcCkq3u2Ynx/AZJwII4CsZawBCeGpojkFjutZBhBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + resolution: {integrity: sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.5.1': + resolution: {integrity: sha512-0Buw+YST9Ff2EqQyhRMQujuFq/dijLw3zXWhsvI0dJ2pGrT38iVoxTEca2eBX/PfhlwkVWSRJEKO5gMqKHyeHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + resolution: {integrity: sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.1': + resolution: {integrity: sha512-x5LRpyCoMKuu3INr18cn42YhwSOaRpp9b5Ny3eJJ/sWVeejC16Zeshu1JlvlSmEZACLeWxU5JM0RX6T6nI+YyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + resolution: {integrity: sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.1': + resolution: {integrity: sha512-mL7tQEuDk3rGfaKmrO63nw6WBy1TPVioQGmVyjcAr+C+a/k3SVMYBytNSMqH9mZPT/Swj30JaZ2N842Z1NnOEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + resolution: {integrity: sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.1': + resolution: {integrity: sha512-uLGc9jtxwiDe4cQyhNPQim4ECiB/YzbWlXssfLAv8r374kArfTwBHjwR2+j+XVyF71yCx5mF9YNRclRqdNBgeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + resolution: {integrity: sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-musl@0.5.1': + resolution: {integrity: sha512-G0cywinaQUc8v1MrytvYKUX7svTDMmYtOCZfxjwKPbJi59zIqM+Ll0rWgO0GQWLZH3XoPYo19ZwlP3Xt9ep9YA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1': + resolution: {integrity: sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@astrojs/compiler-binding-wasm32-wasi@0.5.1': + resolution: {integrity: sha512-yMhLL8asmLydoAdnEZxAw6eLfNDs3YJl6jqn0pjPt6zrb2U5qIDLnii4/twThpIFODKkQ6YDpBRTOz/vSfZCxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + resolution: {integrity: sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.1': + resolution: {integrity: sha512-aClIG6poFt3Wf/bZykrEAX2nAnKvBHW6RXiKUa69QjBtWH0/FOlOAjnFcxRNlhej2AMWNYFx0o+LGjwMcH8P7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + resolution: {integrity: sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.1': + resolution: {integrity: sha512-/E6uBCagY70YpWqssBC+/FuniNSloeQPRgj7l3LDWiqjxIyTLnIgX1sTt18FU7jrigetpSnC9cHAM6b6IxSgPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.4.1': + resolution: {integrity: sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-binding@0.5.1': + resolution: {integrity: sha512-oczyolLUl9mNrtuBuNjuvn5thpHrFLXAHAWGTRg0JDHQ8cGVsVeFekOu2uSoQGgainJIi+A3/9m0DyYwz3Nlnw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.4.1': + resolution: {integrity: sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A==} + engines: {node: '>=22.12.0'} + + '@astrojs/compiler-rs@0.5.1': + resolution: {integrity: sha512-1k1acnX7DepIiea9U9/Y+d1hdAFfylu1ZQQkkYgRSwBOBAsXInolyZuh/2tax+83vu/A+hOYP6P3+265RvMTvw==} + engines: {node: '>=22.12.0'} + + '@astrojs/internal-helpers@0.11.0': + resolution: {integrity: sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==} + + '@astrojs/language-server@2.17.1': + resolution: {integrity: sha512-gG7bvMVNIXgoSe1pM9dI1HsSDLU1EIc/dSAYQ0DuReLyLkOBjsiqvaRYgQltWaldLxh0bNEqjgreLfjd9OnUxQ==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-satteri@0.4.2': + resolution: {integrity: sha512-g4QehXnB/MzxQWnSf17NciFInMqEJy9948p8cc7zGg/r7995m0Ln5PMr3Xlnj24iey1LchaoZ3SzTOa3D4P/3w==} + + '@astrojs/mdx@8.0.2': + resolution: {integrity: sha512-WedIVP2Bu8fXSzxb3ZYZ8xDrWWjf2SRKCneh52Llhoa+O3lcpHPhoZVP3FerlivUaH/0LJz2yVaCNvMdg0u8Bw==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + '@astrojs/markdown-satteri': ^0.4.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/sitemap@3.7.4': + resolution: {integrity: sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==} + + '@astrojs/starlight@0.42.5': + resolution: {integrity: sha512-S6hjUexk9rrKtjJF3WR8T9dM1woNR7X2XU8e3wj4OpRT4X15AXk/mzPjWe/Qd5+P8whHj2+ves+k06qirBzimg==} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@commitlint/cli@21.2.3': + resolution: {integrity: sha512-5yOX6IRjcrIReCxpyAd04hgI/yhUMpVBJ/ouwFV0wk9yx5+fYRDWZWJ5h55Tbedgfol+5m2mU5nqzbB8g3v0vQ==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@commitlint/config-conventional@21.2.3': + resolution: {integrity: sha512-EP2n5DN2GwRWNVrLX0Jx3mpQlW5PfFi9HnD9OJ8hN/H0xFqpCFyHeGDhg+0P8/21iMMcdkcQgbNqmVklebqYsQ==} + engines: {node: '>=22.12.0'} + + '@commitlint/config-validator@21.2.3': + resolution: {integrity: sha512-JQ13v0GafSM4fBYX8UowSy++07Was20auq0NcTdnzSEq6/05Cq5GBUMIfKkqjzyBwC9sJFmPpKWgLLhH7cygkw==} + engines: {node: '>=22.12.0'} + + '@commitlint/ensure@21.2.3': + resolution: {integrity: sha512-gEjNKgRocbODI5/sMx2Qc9XOf2/zFTNV2aEK1QZ4gKDKxDC/VR7us3QtIX1C5d5xxHxx0I/tEbYbdSP1Am2LwA==} + engines: {node: '>=22.12.0'} + + '@commitlint/execute-rule@21.0.1': + resolution: {integrity: sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==} + engines: {node: '>=22.12.0'} + + '@commitlint/format@21.2.3': + resolution: {integrity: sha512-KKwCjXYqA8r295uhLnulDXL9yekdXmj2yp2Q0XsBPhxQ2iPuvRE8F43j7g7yexo69wTdmUQmFz15jpderluzUQ==} + engines: {node: '>=22.12.0'} + + '@commitlint/is-ignored@21.2.3': + resolution: {integrity: sha512-Ng4nRj/LBAOvc+GQu7bsFW4/fL89LvZ14OOC/ERLbUpnG6QBwWRzY/1oXGwPs3C8YHWjJObin4kLYo2YeEn70w==} + engines: {node: '>=22.12.0'} + + '@commitlint/lint@21.2.3': + resolution: {integrity: sha512-lUZcSuVLLEDkhge9ERk8Rp+FXQtrUhCK+h328BFnzKXrTuXGdqiYMEpTlNWdZaOrjyMhMJaz354ZaWEGJxcLeg==} + engines: {node: '>=22.12.0'} + + '@commitlint/load@21.2.3': + resolution: {integrity: sha512-cDrL8lOo23Lk7knGzQdjbFcq7z2JgvjwV6PhbESVvEc+Fe21MeLjPKBJi8nKjg7j+jh79CVbVSUbZc104thXRw==} + engines: {node: '>=22.12.0'} + + '@commitlint/message@21.2.0': + resolution: {integrity: sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==} + engines: {node: '>=22.12.0'} + + '@commitlint/parse@21.2.3': + resolution: {integrity: sha512-Jt61QpF1xA88damCELe8I0FlDZPSpXNc8IhN7z5kZ0EcDYPjdZ+ZHPvsSXl69r2MIlLnNKeHhOn2cmVpziFE9w==} + engines: {node: '>=22.12.0'} + + '@commitlint/read@21.2.3': + resolution: {integrity: sha512-szAepuLA1vTTr7fuudEg7jYyVHudf+FH+vptU91bhxE1WnE8TKrfTAi44HVllz8XpI8Pvp4GjR/iP5Nshgh+og==} + engines: {node: '>=22.12.0'} + + '@commitlint/resolve-extends@21.2.3': + resolution: {integrity: sha512-bqJdmAtXgAQel+l02uIsF7rHgKgx1AGZdGBjam7uK4XW54ZAQtB6XAIUXezYprQiCcRMpBh4fHs5JodXK4mVZA==} + engines: {node: '>=22.12.0'} + + '@commitlint/rules@21.2.3': + resolution: {integrity: sha512-5+YnI/LuTx3JDl9GjqfP2z9x96M8KKDrd+4jOel/T0L1BB3oAmBlSN8xppf63QsudiVEgR8C0gRoAMrPVrms6g==} + engines: {node: '>=22.12.0'} + + '@commitlint/to-lines@21.0.1': + resolution: {integrity: sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==} + engines: {node: '>=22.12.0'} + + '@commitlint/top-level@21.2.0': + resolution: {integrity: sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==} + engines: {node: '>=22.12.0'} + + '@commitlint/types@21.2.3': + resolution: {integrity: sha512-sGEA473TlCvhCGNoTBY9OXZVWdscIXdGt0dm8EpB+2MRfmt8LNh/X56P/T3nCtEB+eH5qGEa3odphSAoUxSfSQ==} + engines: {node: '>=22.12.0'} + + '@conventional-changelog/git-client@3.1.2': + resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} + engines: {node: '>=22'} + peerDependencies: + conventional-commits-filter: ^6.0.1 + conventional-commits-parser: ^7.1.2 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@conventional-changelog/template@1.4.0': + resolution: {integrity: sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==} + engines: {node: '>=22'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.8.1': + resolution: {integrity: sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@expressive-code/core@0.44.2': + resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} + + '@expressive-code/plugin-frames@0.44.2': + resolution: {integrity: sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==} + + '@expressive-code/plugin-shiki@0.44.2': + resolution: {integrity: sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==} + + '@expressive-code/plugin-text-markers@0.44.2': + resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} + + '@fontsource/overpass@5.3.0': + resolution: {integrity: sha512-601DJ9xb8+8XbxAS05+lkh9cW6wc4TnXL2vhQBUN6PrtE/mQTgxqrqac7rXkthyJO7c3KMHOpl9Tt0sRql1p5Q==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.5.2': + resolution: {integrity: sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==} + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + + '@prettier/parse-srcset@3.1.0': + resolution: {integrity: sha512-FIRv2rZotO9NP/r66taYqD1zICpPiBbrECqjKV/xqNotqDxF7fLzzUeK+1RSRx9Tenk0DajR/GcRmTJ2qtHaKQ==} + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@shikijs/core@4.5.0': + resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.5.0': + resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.5.0': + resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==} + engines: {node: '>=20'} + + '@shikijs/langs@4.5.0': + resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.5.0': + resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==} + engines: {node: '>=20'} + + '@shikijs/themes@4.5.0': + resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==} + engines: {node: '>=20'} + + '@shikijs/types@4.5.0': + resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@simple-libs/child-process-utils@2.0.0': + resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} + engines: {node: '>=22'} + + '@simple-libs/stream-utils@2.0.0': + resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} + engines: {node: '>=22'} + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.71.0': + resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.4.0: + resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==} + hasBin: true + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansi-styles@7.0.0: + resolution: {integrity: sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew==} + engines: {node: '>=22'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + argue-cli@3.2.0: + resolution: {integrity: sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw==} + engines: {node: '>=22'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astro-expressive-code@0.44.2: + resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 + + astro@7.3.5: + resolution: {integrity: sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.1: + resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + conventional-changelog-angular@9.4.0: + resolution: {integrity: sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==} + engines: {node: '>=22'} + + conventional-changelog-conventionalcommits@10.4.0: + resolution: {integrity: sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA==} + engines: {node: '>=22'} + + conventional-commits-parser@7.1.2: + resolution: {integrity: sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==} + engines: {node: '>=22'} + hasBin: true + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.11.0: + resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + expressive-code@0.44.2: + resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + find-proc@0.2.0: + resolution: {integrity: sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==} + engines: {node: ^20.19.0 || >=22.12.0} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + i18next@26.4.2: + resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.11: + resolution: {integrity: sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-parse-even-better-errors@6.0.0: + resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.3: + resolution: {integrity: sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-normalize-package-bin@6.0.0: + resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-run-all2@9.0.3: + resolution: {integrity: sha512-BQAEdU1PtYc48qYRdghW2BVTQT3VqWCoFQmO87NlM1h1PYwMCKQpUWaNyB20V26caNzFsXQDfyOdznLlHCih6g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pidtree@1.0.0: + resolution: {integrity: sha512-avfAvjB9Dd0wdj3rjJX//yS+G79OO0KrS5pJHFJENjYGX6N4SMgEDBBI/yFy0lloOYSaC6XQxzpOAMPfSYFV/Q==} + engines: {node: '>=18'} + hasBin: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-astro@1.1.0: + resolution: {integrity: sha512-ujJ1VX+iin4eYJPHVCEbmXHzrEVsi1SaVehHtbAteTZv8VTrZyBiZPmYc6Ud6+fkr/UBMXuEu3N3+d49h7YfKw==} + engines: {node: '>=22.12.0'} + peerDependencies: + prettier: ^3.5.3 + + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + read-package-json-fast@6.0.0: + resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.44.2: + resolution: {integrity: sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==} + + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + s.color@0.0.15: + resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==} + + sass-formatter@0.8.0: + resolution: {integrity: sha512-Nf4eMGE2O5vnJG7KPCRyksWS9gVKSYoUWtPeMXRRxwZSPVQhSGtL602xVYS65LvMkHmkvKa7xpauF0+/5pPwrg==} + hasBin: true + + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.11.0: + resolution: {integrity: sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==} + engines: {node: '>= 0.4'} + + shiki@4.5.0: + resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + suf-log@2.5.3: + resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyclip@1.0.3: + resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript-eslint@8.71.0: + resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-extras@0.1.0: + resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} + engines: {node: '>=20'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + verkit@0.4.1: + resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} + engines: {node: '>=18.12.0'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + typescript: '*' + peerDependenciesMeta: + '@volar/language-service': + optional: true + typescript: + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + typescript: '*' + peerDependenciesMeta: + '@volar/language-service': + optional: true + typescript: + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-jsonrpc@9.0.3: + resolution: {integrity: sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-protocol@3.18.4: + resolution: {integrity: sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg==} + + vscode-languageserver-textdocument@1.0.15: + resolution: {integrity: sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.4: + resolution: {integrity: sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/astro2tsx@0.1.2(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/runtime' + + '@astrojs/check@0.9.10(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9))(prettier@3.9.9)(typescript@6.0.3)': + dependencies: + '@astrojs/language-server': 2.17.1(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9))(prettier@3.9.9)(typescript@6.0.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 6.0.3 + yargs: 18.2.0 + transitivePeerDependencies: + - '@emnapi/runtime' + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-android-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-android-arm64@0.5.1': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.5.1': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.5.1': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.5.1': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.1': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.1': + optional: true + + '@astrojs/compiler-binding@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-x64': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-musl': 0.4.1 + '@astrojs/compiler-binding-linux-x64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-x64-musl': 0.4.1 + '@astrojs/compiler-binding-wasm32-wasi': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.1 + '@astrojs/compiler-binding-win32-x64-msvc': 0.4.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/compiler-binding@0.5.1': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.5.1 + '@astrojs/compiler-binding-darwin-arm64': 0.5.1 + '@astrojs/compiler-binding-darwin-x64': 0.5.1 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.1 + '@astrojs/compiler-binding-linux-arm64-musl': 0.5.1 + '@astrojs/compiler-binding-linux-x64-gnu': 0.5.1 + '@astrojs/compiler-binding-linux-x64-musl': 0.5.1 + '@astrojs/compiler-binding-wasm32-wasi': 0.5.1 + '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.1 + '@astrojs/compiler-binding-win32-x64-msvc': 0.5.1 + + '@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@astrojs/compiler-binding': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/compiler-rs@0.5.1': + dependencies: + '@astrojs/compiler-binding': 0.5.1 + + '@astrojs/internal-helpers@0.11.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + js-yaml: 4.3.2 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.5.0 + smol-toml: 1.9.0 + unified: 11.0.5 + + '@astrojs/language-server@2.17.1(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9))(prettier@3.9.9)(typescript@6.0.3)': + dependencies: + '@astrojs/astro2tsx': 0.1.2(@emnapi/runtime@1.11.3) + '@astrojs/yaml2ts': 0.2.4 + '@volar/kit': 2.4.28(typescript@6.0.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28(typescript@6.0.3) + '@volar/language-service': 2.4.28(typescript@6.0.3) + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-html: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.9) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.2.0 + optionalDependencies: + prettier: 3.9.9 + prettier-plugin-astro: 1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9) + transitivePeerDependencies: + - '@emnapi/runtime' + - typescript + + '@astrojs/markdown-satteri@0.4.2': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/mdx@8.0.2(@astrojs/markdown-satteri@0.4.2)(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.2 + astro: 7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1) + es-module-lexer: 2.3.2 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.4': + dependencies: + sitemap: 9.0.1 + zod: 4.6.5 + + '@astrojs/starlight@0.42.5(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typescript@6.0.3)': + dependencies: + '@astrojs/markdown-satteri': 0.4.2 + '@astrojs/mdx': 8.0.2(@astrojs/markdown-satteri@0.4.2)(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1)) + '@astrojs/sitemap': 3.7.4 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.5 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1) + astro-expressive-code: 0.44.2(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1)) + bcp-47: 2.1.1 + hast-util-format: 1.1.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.4.2(typescript@6.0.3) + js-yaml: 4.3.2 + klona: 2.0.6 + magic-string: 1.4.2 + mdast-util-directive: 3.1.0 + mdast-util-to-markdown: 2.1.3 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + remark-directive: 4.0.0 + satteri: 0.10.5 + ultrahtml: 1.7.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@astrojs/telemetry@3.3.3': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.8.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@commitlint/cli@21.2.3(@types/node@24.19.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3)': + dependencies: + '@commitlint/config-conventional': 21.2.3 + '@commitlint/format': 21.2.3 + '@commitlint/lint': 21.2.3 + '@commitlint/load': 21.2.3(@types/node@24.19.1)(typescript@6.0.3) + '@commitlint/read': 21.2.3(conventional-commits-parser@7.1.2) + '@commitlint/types': 21.2.3 + tinyexec: 1.3.1 + yargs: 18.2.0 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@21.2.3': + dependencies: + '@commitlint/types': 21.2.3 + conventional-changelog-conventionalcommits: 10.4.0 + + '@commitlint/config-validator@21.2.3': + dependencies: + '@commitlint/types': 21.2.3 + ajv: 8.20.0 + + '@commitlint/ensure@21.2.3': + dependencies: + '@commitlint/types': 21.2.3 + es-toolkit: 1.52.0 + + '@commitlint/execute-rule@21.0.1': {} + + '@commitlint/format@21.2.3': + dependencies: + '@commitlint/types': 21.2.3 + picocolors: 1.1.1 + + '@commitlint/is-ignored@21.2.3': + dependencies: + '@commitlint/types': 21.2.3 + semver: 7.8.5 + + '@commitlint/lint@21.2.3': + dependencies: + '@commitlint/is-ignored': 21.2.3 + '@commitlint/parse': 21.2.3 + '@commitlint/rules': 21.2.3 + '@commitlint/types': 21.2.3 + + '@commitlint/load@21.2.3(@types/node@24.19.1)(typescript@6.0.3)': + dependencies: + '@commitlint/config-validator': 21.2.3 + '@commitlint/execute-rule': 21.0.1 + '@commitlint/resolve-extends': 21.2.3 + '@commitlint/types': 21.2.3 + cosmiconfig: 9.0.2(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.19.1)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3) + es-toolkit: 1.52.0 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@21.2.0': {} + + '@commitlint/parse@21.2.3': + dependencies: + '@commitlint/types': 21.2.3 + conventional-changelog-angular: 9.4.0 + conventional-commits-parser: 7.1.2 + + '@commitlint/read@21.2.3(conventional-commits-parser@7.1.2)': + dependencies: + '@commitlint/top-level': 21.2.0 + '@commitlint/types': 21.2.3 + '@conventional-changelog/git-client': 3.1.2(conventional-commits-parser@7.1.2) + tinyexec: 1.3.1 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@21.2.3': + dependencies: + '@commitlint/config-validator': 21.2.3 + '@commitlint/types': 21.2.3 + es-toolkit: 1.52.0 + global-directory: 5.0.0 + resolve-from: 5.0.0 + + '@commitlint/rules@21.2.3': + dependencies: + '@commitlint/ensure': 21.2.3 + '@commitlint/message': 21.2.0 + '@commitlint/to-lines': 21.0.1 + '@commitlint/types': 21.2.3 + + '@commitlint/to-lines@21.0.1': {} + + '@commitlint/top-level@21.2.0': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@21.2.3': + dependencies: + conventional-commits-parser: 7.1.2 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': + dependencies: + '@simple-libs/child-process-utils': 2.0.0 + '@simple-libs/stream-utils': 2.0.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 7.1.2 + + '@conventional-changelog/template@1.4.0': {} + + '@ctrl/tinycolor@4.2.1': {} + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.8.1(eslint@10.11.0(jiti@2.6.1))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.11.0(jiti@2.6.1) + ignore: 7.0.11 + + '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.6.1))': + dependencies: + eslint: 10.11.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.11.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.11.0(jiti@2.6.1) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@expressive-code/core@0.44.2': + dependencies: + '@ctrl/tinycolor': 4.2.1 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.28 + postcss-nested: 6.2.0(postcss@8.5.28) + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@expressive-code/plugin-shiki@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + shiki: 4.5.0 + + '@expressive-code/plugin-text-markers@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@fontsource/overpass@5.3.0': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.152.0': {} + + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + + '@prettier/parse-srcset@3.1.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@shikijs/core@4.5.0': + dependencies: + '@shikijs/primitive': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/primitive@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/types@4.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@simple-libs/child-process-utils@2.0.0': + dependencies: + '@simple-libs/stream-utils': 2.0.0 + + '@simple-libs/stream-utils@2.0.0': {} + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 24.19.1 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 10.11.0(jiti@2.6.1) + ignore: 7.0.11 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + eslint: 10.11.0(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.71.0': {} + + '@typescript-eslint/typescript-estree@8.71.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) + eslint: 10.11.0(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.4.0': {} + + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + + '@vitest/spy@5.0.3': {} + + '@volar/kit@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + '@volar/typescript': 2.4.28(typescript@6.0.3) + typesafe-path: 0.2.2 + typescript: 6.0.3 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28(typescript@6.0.3) + '@volar/typescript': 2.4.28(typescript@6.0.3) + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.18.4 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@volar/language-service@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.18.4 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + + '@vscode/l10n@0.0.18': {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.4.0: + dependencies: + process-ancestry: 0.1.0 + + ansi-regex@6.4.0: {} + + ansi-styles@6.2.3: {} + + ansi-styles@7.0.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + argue-cli@3.2.0: {} + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + astro-expressive-code@0.44.2(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1)): + dependencies: + astro: 7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1) + rehype-expressive-code: 0.44.2 + url-extras: 0.1.0 + + astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1): + dependencies: + '@astrojs/compiler-rs': 0.5.1 + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.2 + '@astrojs/telemetry': 3.3.3 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.4.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 5.9.4 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 2.3.2 + esbuild: 0.28.2 + find-proc: 0.2.0 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.3.2 + jsonc-parser: 3.3.1 + magic-string: 1.4.2 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 3.0.0 + p-limit: 7.3.3 + p-queue: 9.3.3 + package-manager-detector: 1.8.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + shiki: 4.5.0 + smol-toml: 1.9.0 + svgo: 4.1.0 + tinyclip: 1.0.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + ultrahtml: 1.7.0 + unifont: 0.7.5 + unstorage: 1.17.5 + verkit: 0.4.1 + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.5 + optionalDependencies: + sharp: 0.35.5(@types/node@24.19.1) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + balanced-match@4.0.4: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.1: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + boolbase@1.0.0: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + callsites@3.1.0: {} + + ccount@2.0.1: {} + + chai@6.3.0: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + conventional-changelog-angular@9.4.0: + dependencies: + '@conventional-changelog/template': 1.4.0 + + conventional-changelog-conventionalcommits@10.4.0: + dependencies: + '@conventional-changelog/template': 1.4.0 + + conventional-commits-parser@7.1.2: + dependencies: + '@simple-libs/stream-utils': 2.0.0 + argue-cli: 3.2.0 + + cookie-es@1.2.3: {} + + cookie@2.0.1: {} + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.19.1)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): + dependencies: + '@types/node': 24.19.1 + cosmiconfig: 9.0.2(typescript@6.0.3) + jiti: 2.6.1 + typescript: 6.0.3 + + cosmiconfig@9.0.2(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css-what@7.0.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-is@0.1.4: {} + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.9.4: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + + direction@2.0.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + + entities@4.5.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-module-lexer@2.3.2: {} + + es-toolkit@1.52.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.11.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + expressive-code@0.44.2: + dependencies: + '@expressive-code/core': 0.44.2 + '@expressive-code/plugin-frames': 0.44.2 + '@expressive-code/plugin-shiki': 0.44.2 + '@expressive-code/plugin-text-markers': 0.44.2 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + find-proc@0.2.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.7.0: {} + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.2.0: {} + + i18next@26.4.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + ignore@5.3.2: {} + + ignore@7.0.11: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@6.0.0: {} + + iron-webcrypto@1.2.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.2.1: {} + + is-decimal@2.0.1: {} + + is-docker@4.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@2.3.1: {} + + json-parse-even-better-errors@6.0.0: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kleur@4.1.5: {} + + klona@2.0.6: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + longest-streak@3.1.0: {} + + lru-cache@11.5.3: {} + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + memorystream@0.3.1: {} + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + parse-entities: 4.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: {} + + natural-compare@1.4.0: {} + + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + npm-normalize-package-bin@6.0.0: {} + + npm-run-all2@9.0.3: + dependencies: + ansi-styles: 7.0.0 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + picomatch: 4.0.7 + pidtree: 1.0.0 + read-package-json-fast: 6.0.0 + shell-quote: 1.11.0 + which: 7.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.2.1: {} + + obug@3.0.0: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + + package-manager-detector@1.8.0: {} + + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pidtree@1.0.0: {} + + postcss-nested@6.2.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prelude-ls@1.2.1: {} + + prettier-plugin-astro@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9): + dependencies: + '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@prettier/parse-srcset': 3.1.0 + prettier: 3.9.9 + sass-formatter: 0.8.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + prettier@3.9.9: {} + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + property-information@7.2.0: {} + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + radix3@1.1.2: {} + + read-package-json-fast@6.0.0: + dependencies: + json-parse-even-better-errors: 6.0.0 + npm-normalize-package-bin: 6.0.0 + + readdirp@4.1.2: {} + + readdirp@5.1.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.44.2: + dependencies: + expressive-code: 0.44.2 + + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + s.color@0.0.15: {} + + sass-formatter@0.8.0: + dependencies: + suf-log: 2.5.3 + + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + + semver@7.8.5: {} + + sharp@0.35.5(@types/node@24.19.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 24.19.1 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.11.0: {} + + shiki@4.5.0: + dependencies: + '@shikijs/core': 4.5.0 + '@shikijs/engine-javascript': 4.5.0 + '@shikijs/engine-oniguruma': 4.5.0 + '@shikijs/langs': 4.5.0 + '@shikijs/themes': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.19.1 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.1 + + smol-toml@1.9.0: {} + + source-map-js@1.2.2: {} + + space-separated-tokens@2.0.2: {} + + std-env@4.3.0: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + suf-log@2.5.3: + dependencies: + s.color: 0.0.15 + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + tiny-inflate@1.0.3: {} + + tinybench@6.2.0: {} + + tinyclip@1.0.3: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript-eslint@8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.11.0(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.24.6: {} + + undici@8.11.2: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.5: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.12 + undici: 8.11.2 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-extras@0.1.0: {} + + util-deprecate@1.0.2: {} + + verkit@0.4.1: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.1 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.9.1 + + vitefu@1.1.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): + optionalDependencies: + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + + vitest@5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 24.19.1 + transitivePeerDependencies: + - msw + + volar-service-css@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + volar-service-html@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.9): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + prettier: 3.9.9 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + typescript: 6.0.3 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3): + dependencies: + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.15 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + typescript: 6.0.3 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + vscode-uri: 3.2.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.2.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-jsonrpc@9.0.3: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-protocol@3.18.4: + dependencies: + vscode-jsonrpc: 9.0.3 + vscode-languageserver-types: 3.18.4 + + vscode-languageserver-textdocument@1.0.15: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.4: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.2.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@7.0.0: + dependencies: + isexe: 4.0.0 + + why-is-node-running@3.2.1: {} + + word-wrap@1.2.5: {} + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.9 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.1: {} + + yargs-parser@22.0.0: {} + + yargs@18.2.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.3.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} + + zod@4.6.5: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..49c0ad7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: false diff --git a/public/apple-touch-icon-180.png b/public/apple-touch-icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..04b512bab5498f5b00328f06bcbfeca76c35b5f6 GIT binary patch literal 614 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQR?Zu_bxCyDN{GPbS4TFsEVDfi#Vs5U~=#(x7%mFjrE3J;e;cOLhPP0Dx@%${N8xHcF*2^+Xa_5=-jV(PMDRefg@J+J7!7%w@ ze>^1lzr4Y;lc?4$nR?%{>+kgY8@C^FBH)tD#s4jrZTfG-J{ip>>&oXE|8xHzy~o8Z zh~Fzq=Kg;^^Y5-75e9 literal 0 HcmV?d00001 diff --git a/public/favicon-32.png b/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..e8caaaaa6aae4cc0bfa8f98084579a81a089a6a1 GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={W*pj^6T^Rm@;DWu&Cj&(?JzX3_ zJUWyA{Qqyytjj3u>nkrNwn3rz$5EFRd5pES42<&edlxQZb+(_xuEaNi@#)K~1wg?m zOI9zGKiRAd6j}K)>%&h5rVjsbkeX~61?E35vowA%F#Qu#NfMR+HPI&PAjbv + Gatepost + + + + diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..cfed83e --- /dev/null +++ b/renovate.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "description": [ + "Each pull request body ends with the sign-off line that the pull request job needs.", + "prHeader holds the line because a JSON string cannot wrap, and the template ends with it." + ], + "extends": ["schedule:weekly", "helpers:pinGitHubActionDigests", ":gitSignOff"], + "semanticCommits": "enabled", + "semanticCommitType": "chore", + "semanticCommitScope": "deps", + "commitMessageTopic": "{{depName}}", + "customManagers": [ + { + "description": "Pins of the uvx tools in the scripts of package.json", + "customType": "regex", + "managerFilePatterns": ["/^package\\.json$/"], + "matchStrings": [ + "uvx (?:--with \\S+ )*(?[A-Za-z0-9][A-Za-z0-9._-]*)@(?[^\\s\"]+)", + "--with (?[A-Za-z0-9][A-Za-z0-9._-]*)==(?[^\\s\"]+)" + ], + "datasourceTemplate": "pypi" + } + ], + "packageRules": [ + { + "matchPackageNames": ["@eslint-community/eslint-plugin-eslint-comments"], + "commitMessageTopic": "eslint-comments plugin" + }, + { + "description": [ + "The standards name the TypeScript version.", + "TypeScript 6.1 and later wait until typescript-eslint supports them.", + "An update to 6.1 or later needs a change to the standards first." + ], + "matchPackageNames": ["typescript"], + "allowedVersions": "<6.1" + }, + { + "description": [ + "The site builds on Node 24, so its types follow Node 24.", + "A major update needs a change to the Node version of the site first." + ], + "matchPackageNames": ["@types/node"], + "matchUpdateTypes": ["major"], + "enabled": false + } + ], + "prHeader": "Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>", + "prBodyTemplate": "Weekly dependency update.\n\n{{{header}}}" +} diff --git a/scripts/check-licences.ts b/scripts/check-licences.ts new file mode 100644 index 0000000..00d5b49 --- /dev/null +++ b/scripts/check-licences.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Checks each dependency against DEP-4: a permissive licence, or an exception with its reason. +// It reads `pnpm licenses list --json`, so run `pnpm install` first. +import { execFileSync } from 'node:child_process'; +import process from 'node:process'; + +/** The packages of one licence, as `pnpm licenses list --json` lists them. */ +export type LicenceReport = Readonly>; + +// The licences that DEP-4 names, and three more permissive ones that tools in the tree use. +const ALLOWED = [ + 'MIT', + 'ISC', + 'Apache-2.0', + 'BSD-2-Clause', + 'BSD-3-Clause', + '0BSD', + 'BlueOak-1.0.0', + 'CC0-1.0', + 'Python-2.0', +]; + +// A name and a licence that may pass, and the reason. A change of licence fails the check. +const EXCEPTIONS: readonly { name: RegExp; licence: string; reason: string }[] = [ + { + name: /^@fontsource\/overpass$/, + licence: 'OFL-1.1', + reason: 'The brand font. Each font file holds its copyright and a link to the licence.', + }, + { + name: /^@img\/sharp-libvips-/, + licence: 'LGPL-3.0-or-later', + reason: 'Astro runs it at build time. The site does not ship it, and we do not change it.', + }, + { + name: /^lightningcss(?:-.+)?$/, + licence: 'MPL-2.0', + reason: 'A build tool that Vite runs, unchanged. The site does not ship it.', + }, +]; + +/** + * Lists the packages that DEP-4 does not allow. + * + * @param report - The packages by licence. + * @returns One line for each package that breaks the rule. + */ +export function findViolations(report: LicenceReport): readonly string[] { + return Object.entries(report).flatMap(([licence, packages]) => + ALLOWED.includes(licence) + ? [] + : packages + .filter(({ name }) => !EXCEPTIONS.some((e) => e.licence === licence && e.name.test(name))) + .map(({ name }) => `${name} uses ${licence}, which DEP-4 does not allow.`), + ); +} + +if (import.meta.main) { + const json = execFileSync('pnpm', ['licenses', 'list', '--json'], { encoding: 'utf8' }); + const violations = findViolations(JSON.parse(json) as LicenceReport); + process.stdout.write(violations.map((line) => `${line}\n`).join('')); + process.exitCode = violations.length === 0 ? 0 : 1; +} diff --git a/scripts/check-prose.ts b/scripts/check-prose.ts new file mode 100644 index 0000000..c03c2fb --- /dev/null +++ b/scripts/check-prose.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Checks the prose of the hand-written pages against the parts of DOC-4 that a script can see: +// sentence length, semicolons, dashes and marketing words. A reviewer checks the rest of DOC-4, +// such as the active voice and phrasal verbs. The pages that the build writes from the spec and +// the doc comments are not checked here, because their own repos own that text. +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import process from 'node:process'; + +/** One break of a prose rule, with the line of the page where it starts. */ +export interface ProseProblem { + readonly line: number; + readonly message: string; +} + +const MAX_WORDS = 25; +const EM_DASH = String.fromCodePoint(0x2014); +const EN_DASH = String.fromCodePoint(0x2013); +// DOC-4 names "powerful" and "seamless". The others are the words that make docs read like +// an advert or like a machine wrote them. +const MARKETING_WORDS = [ + 'powerful', + 'seamless', + 'seamlessly', + 'simply', + 'just', + 'easy', + 'easily', + 'effortless', + 'robust', + 'leverage', + 'utilise', + 'utilize', + 'cutting-edge', + 'blazing', + 'delve', +]; +const MARKETING = new RegExp(`\\b(${MARKETING_WORDS.join('|')})\\b`, 'i'); + +// The text of a line that a reader sees as prose: no code, no link targets, no HTML or MDX tags. +function proseOf(line: string): string { + return line + .replace(/`[^`]*`/g, 'code') + .replace(/\]\([^)]*\)/g, ']') + .replace(/<[^>]+>/g, ' ') + .replace(/^\s*(?:[-*+]|\d+\.|#+|>)\s+/, '') + .replace(/^\s*\|/, '') + .replace(/\|/g, '.'); +} + +// Front matter and code fences hold no prose, except the title and the description. +function proseLines(text: string): readonly (readonly [number, string])[] { + const lines = text.split('\n'); + const prose: (readonly [number, string])[] = []; + let inFence = false; + let inFrontMatter = lines[0] === '---'; + lines.forEach((line, index) => { + if (inFrontMatter) { + inFrontMatter = index === 0 || line !== '---'; + const field = /^(?:title|description|\s+tagline):\s*(.*)$/.exec(line); + if (field?.[1] !== undefined) prose.push([index + 1, field[1]]); + return; + } + if (/^\s*(?:```|~~~)/.test(line)) { + inFence = !inFence; + return; + } + if (!inFence && !/^\s*(?:import |export |\|\s*-)/.test(line)) prose.push([index + 1, line]); + }); + return prose; +} + +/** + * Finds the breaks of the prose rules in one page. + * + * @param text - The Markdown or MDX source of the page. + * @returns Each problem, in the order of the lines. + */ +export function checkProse(text: string): readonly ProseProblem[] { + const problems: ProseProblem[] = []; + for (const [line, raw] of proseLines(text)) { + const prose = proseOf(raw); + if (prose.includes(';')) problems.push({ line, message: 'Use a full stop, not a semicolon.' }); + if (prose.includes(EM_DASH) || prose.includes(` ${EN_DASH} `)) { + problems.push({ line, message: 'Use a full stop or a comma, not a dash.' }); + } + const word = MARKETING.exec(prose)?.[1]; + if (word !== undefined) problems.push({ line, message: `Use a plain word, not "${word}".` }); + for (const sentence of prose.split(/[.?!:](?:\s|$)/)) { + const words = sentence.split(/\s+/).filter((part) => /\w/.test(part)).length; + if (words > MAX_WORDS) { + problems.push({ line, message: `A sentence has ${String(words)} words. Use 25 or fewer.` }); + } + } + } + return problems; +} + +/** + * Lists the hand-written pages: the Markdown and MDX files under src/content/docs that git + * tracks, or would track. The build writes the other pages there, and git ignores them. + * + * @returns The paths of the pages, relative to the root of the repo. + */ +export function trackedPages(): readonly string[] { + const output = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { + encoding: 'utf8', + }); + return output + .split('\n') + .filter((path) => /^src\/content\/docs\/.+\.mdx?$/.test(path) && existsSync(path)); +} + +if (import.meta.main) { + let count = 0; + for (const path of trackedPages()) { + for (const { line, message } of checkProse(readFileSync(path, 'utf8'))) { + process.stdout.write(`${path}:${String(line)}: ${message}\n`); + count += 1; + } + } + process.exitCode = count === 0 ? 0 : 1; +} diff --git a/spec b/spec new file mode 160000 index 0000000..9e20f87 --- /dev/null +++ b/spec @@ -0,0 +1 @@ +Subproject commit 9e20f87addb2a2b238cea82d65b9bd071bedc3f6 diff --git a/src/assets/gatepost-lockup-dark.svg b/src/assets/gatepost-lockup-dark.svg new file mode 100644 index 0000000..091e708 --- /dev/null +++ b/src/assets/gatepost-lockup-dark.svg @@ -0,0 +1,60 @@ + + Gatepost + + + + diff --git a/src/assets/gatepost-lockup.svg b/src/assets/gatepost-lockup.svg new file mode 100644 index 0000000..88a837f --- /dev/null +++ b/src/assets/gatepost-lockup.svg @@ -0,0 +1,60 @@ + + Gatepost + + + + diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 0000000..66d0759 --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,19 @@ +--- +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Every page carries the line that says that NIPOST did not make the site (LIC-3). +import Default from '@astrojs/starlight/components/Footer.astro'; +--- + + +

Unofficial. Not made or endorsed by NIPOST.

+ + diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..a45e1dd --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { docsLoader } from '@astrojs/starlight/loaders'; +import { docsSchema } from '@astrojs/starlight/schema'; +import { defineCollection } from 'astro:content'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx new file mode 100644 index 0000000..1f8c0d1 --- /dev/null +++ b/src/content/docs/index.mdx @@ -0,0 +1,23 @@ +--- +title: Gatepost +description: Unofficial, open-source developer tools for Nigeria's digital postcode. +template: splash +hero: + tagline: Open-source tools that read, check and look up Nigeria's digital postcodes. Unofficial. Not made or endorsed by NIPOST. +--- + +On 1 October 2026, NIPOST launched a digital postcode for addressable buildings and locations in Nigeria. A postcode such as `EK-01-A03-FK-01` has five segments. From large to small, they are the state, the LGA, the district, the area and the unit. The unit is one building. + +Gatepost helps apps, websites and online stores accept these postcodes. + +- Check the form of a postcode offline, with no API key and no network call. +- Clean typed or pasted input: spaces, dashes, full-width characters and invisible characters. +- Suggest a fix for a common typo, such as the letter O where a zero belongs. +- Recognise an old 6-digit postcode. +- Ask NIPOST's gateway about a postcode with your own API key. + +Only NIPOST's gateway can say whether a building has a given postcode. Gatepost checks the form first, so your app calls the gateway less often and with clean input. + +Gatepost is independent. NIPOST did not make it and does not support it. For a problem with NIPOST's own app, data or API, contact NIPOST through its official website. + +The site sets its text in Overpass, a font under the [SIL Open Font License 1.1](https://openfontlicense.org). diff --git a/src/content/docs/privacy.md b/src/content/docs/privacy.md new file mode 100644 index 0000000..ae6e5ca --- /dev/null +++ b/src/content/docs/privacy.md @@ -0,0 +1,14 @@ +--- +title: Privacy +description: What this site keeps, what it sends and what GitHub logs. +--- + +This site has no analytics, no tracking and no cookies. Every script, font, image and style comes from this site itself. No page loads anything from another website. + +## Search + +The search box runs in your browser. It reads an index that this site built when it was published. Your search text stays in your browser. + +## Hosting + +GitHub hosts this site on GitHub Pages. [GitHub logs the IP address of each visitor](https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages#data-collection) for security. The Gatepost team does not see these logs. diff --git a/src/styles/theme.css b/src/styles/theme.css new file mode 100644 index 0000000..c066577 --- /dev/null +++ b/src/styles/theme.css @@ -0,0 +1,57 @@ +/* SPDX-FileCopyrightText: 2026 The Gatepost authors */ +/* SPDX-License-Identifier: Apache-2.0 */ +/* + * The theme tokens of the site (UI-3). The colours come from the brand: near-black #0E1513, white + * and teal #0B8574. Teal text needs a lighter tone on the dark theme and a darker one on the + * light theme to reach 4.5:1, so each theme sets its own text accent. test/theme.test.ts checks + * the contrast of each pair that the site uses. + */ +:root { + --sl-font: 'Overpass'; + --sl-color-white: #ffffff; + --sl-color-gray-1: #e6ebe9; + --sl-color-gray-2: #c5cecb; + --sl-color-gray-3: #97a29f; + --sl-color-gray-4: #5f6a67; + --sl-color-gray-5: #3a4341; + --sl-color-gray-6: #1d2523; + --sl-color-black: #0e1513; + --sl-color-accent-low: #0c3b34; + --sl-color-accent: #0b8574; + --sl-color-accent-high: #8fdfd1; + --sl-color-text-accent: var(--sl-color-accent-high); +} + +:root[data-theme='light'] { + --sl-color-white: #0e1513; + --sl-color-gray-1: #1d2523; + --sl-color-gray-2: #2e3735; + --sl-color-gray-3: #4b5653; + --sl-color-gray-4: #7a8582; + --sl-color-gray-5: #c0c8c5; + --sl-color-gray-6: #e7ecea; + --sl-color-gray-7: #f3f6f5; + --sl-color-black: #ffffff; + --sl-color-accent-low: #c9eee7; + --sl-color-accent: #0b8574; + --sl-color-accent-high: #075a4f; + --sl-color-text-accent: var(--sl-color-accent-high); +} + +h1, +h2, +h3, +h4 { + font-weight: 600; + letter-spacing: -0.01em; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0s !important; + animation-duration: 0s !important; + } +} diff --git a/test/site/built-site.ts b/test/site/built-site.ts new file mode 100644 index 0000000..cd6bacc --- /dev/null +++ b/test/site/built-site.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** The folder that `astro build` writes. */ +export const DIST = fileURLToPath(new URL('../../dist/', import.meta.url)); + +/** A file of the built site, by its path inside dist/. */ +export interface BuiltFile { + readonly path: string; + readonly text: string; +} + +function filesUnder(folder: string): readonly string[] { + return readdirSync(folder, { withFileTypes: true }).flatMap((entry) => { + const path = join(folder, entry.name); + return entry.isDirectory() ? filesUnder(path) : [path]; + }); +} + +/** + * Reads each built file whose name ends with the suffix. + * + * @param suffix - The end of the file name, such as `.html` or `.css`. + * @returns The files, in the order of their paths. + */ +export function builtFiles(suffix: string): readonly BuiltFile[] { + if (!existsSync(DIST)) { + throw new Error('dist/ is missing. Run pnpm build before the site tests.'); + } + return filesUnder(DIST) + .filter((path) => path.endsWith(suffix)) + .sort() + .map((path) => ({ path: relative(DIST, path), text: readFileSync(path, 'utf8') })); +} diff --git a/test/site/pages.test.ts b/test/site/pages.test.ts new file mode 100644 index 0000000..dd881a1 --- /dev/null +++ b/test/site/pages.test.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { builtFiles } from './built-site.ts'; + +const PAGES = builtFiles('.html'); +const STYLES = builtFiles('.css'); + +// Each attribute that makes the browser fetch something when the page loads. An only +// fetches when the reader follows it, so it may point at another site. So may a that +// names the page itself or the sitemap, because the browser does not fetch those. +const FETCHING_TAG = /<(script|link|img|source|iframe|video|audio|embed|object|track)\b[^>]*>/gi; +const NAMING_LINK = /^]*\srel="(?:canonical|alternate|sitemap)"/i; +const FETCHING_ATTRIBUTE = /\s(?:src|href|srcset|poster|data)="([^"]*)"/gi; + +// A URL that the site serves itself: a path, a relative URL, a fragment or inline data. +function isOwnUrl(url: string): boolean { + return !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(url) || url.startsWith('data:'); +} + +function fetchedUrls(html: string): readonly string[] { + return Array.from(html.matchAll(FETCHING_TAG), ([tag]) => tag) + .filter((tag) => !NAMING_LINK.test(tag)) + .map((tag) => Array.from(tag.matchAll(FETCHING_ATTRIBUTE), ([, url]) => url ?? '')) + .flat() + .flatMap((value) => value.split(',').map((part) => part.trim().split(/\s+/)[0] ?? '')); +} + +describe('every built page', () => { + it('includes the home page and the 404 page, so the checks below cannot pass on no page', () => { + expect(PAGES.map(({ path }) => path)).toEqual( + expect.arrayContaining(['index.html', '404.html']), + ); + }); + + it.each(PAGES)('says that NIPOST did not make $path', ({ text }) => { + expect(text).toMatch( + /

Unofficial\. Not made or endorsed by NIPOST\.<\/p>/, + ); + }); + + it.each(PAGES)('declares British English in $path', ({ text }) => { + expect(text).toMatch(/ { + expect(fetchedUrls(text).filter((url) => !isOwnUrl(url))).toEqual([]); + }); +}); + +describe('every built style sheet', () => { + it.each(STYLES)('loads nothing from another site in $path', ({ text }) => { + const urls = Array.from(text.matchAll(/url\(\s*['"]?([^'")]+)/g), ([, url]) => url ?? ''); + const imports = Array.from(text.matchAll(/@import\s+['"]([^'"]+)/g), ([, url]) => url ?? ''); + expect([...urls, ...imports].filter((url) => !isOwnUrl(url))).toEqual([]); + }); +}); diff --git a/test/unit/check-licences.test.ts b/test/unit/check-licences.test.ts new file mode 100644 index 0000000..d183475 --- /dev/null +++ b/test/unit/check-licences.test.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { findViolations } from '../../scripts/check-licences.ts'; + +describe('findViolations', () => { + it('passes permissive licences and the recorded exceptions', () => { + const report = { + MIT: [{ name: 'astro' }], + 'MPL-2.0': [{ name: 'lightningcss-linux-x64-gnu' }], + 'LGPL-3.0-or-later': [{ name: '@img/sharp-libvips-linux-x64' }], + }; + expect(findViolations(report)).toEqual([]); + }); + + it('reports a package whose licence has no exception', () => { + const report = { 'GPL-3.0-only': [{ name: 'copyleft-tool' }], 'MPL-2.0': [{ name: 'other' }] }; + expect(findViolations(report)).toEqual([ + 'copyleft-tool uses GPL-3.0-only, which DEP-4 does not allow.', + 'other uses MPL-2.0, which DEP-4 does not allow.', + ]); + }); + + it('reports an exception whose licence changed', () => { + expect(findViolations({ 'GPL-2.0-only': [{ name: '@fontsource/overpass' }] })).toEqual([ + '@fontsource/overpass uses GPL-2.0-only, which DEP-4 does not allow.', + ]); + }); +}); diff --git a/test/unit/check-prose.test.ts b/test/unit/check-prose.test.ts new file mode 100644 index 0000000..9e15ee1 --- /dev/null +++ b/test/unit/check-prose.test.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { checkProse } from '../../scripts/check-prose.ts'; + +const EM_DASH = String.fromCodePoint(0x2014); + +function messages(text: string): readonly string[] { + return checkProse(text).map(({ line, message }) => `${String(line)}: ${message}`); +} + +describe('checkProse', () => { + it('accepts short plain sentences', () => { + expect(messages('A postcode has five segments. The unit is one building.\n')).toEqual([]); + }); + + it('reports a sentence of 26 words', () => { + const sentence = `${Array.from({ length: 26 }, () => 'word').join(' ')}.`; + expect(messages(`Intro.\n${sentence}\n`)).toEqual([ + '2: A sentence has 26 words. Use 25 or fewer.', + ]); + }); + + it('reports a semicolon, a dash and a marketing word, each on its own line', () => { + const text = `It parses; it checks.\nIt parses ${EM_DASH} it checks.\nIt is seamless.\n`; + expect(messages(text)).toEqual([ + '1: Use a full stop, not a semicolon.', + '2: Use a full stop or a comma, not a dash.', + '3: Use a plain word, not "seamless".', + ]); + }); + + it('skips code fences, inline code and link targets', () => { + const text = [ + 'Call `a; b` with [the guide](https://example.com/a;b).', + '```ts', + 'const simply = 1;', + '```', + ].join('\n'); + expect(messages(text)).toEqual([]); + }); + + it('checks the title and the description of the front matter, and nothing else there', () => { + const text = ['---', 'title: Easy postcodes', 'sidebar:', ' label: a; b', '---', ''].join( + '\n', + ); + expect(messages(text)).toEqual(['2: Use a plain word, not "Easy".']); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..66aad84 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "astro/tsconfigs/strictest", + "compilerOptions": { + "types": ["node"], + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "noPropertyAccessFromIndexSignature": true + }, + "include": [".astro/types.d.ts", "src", "scripts", "test"], + "exclude": ["dist", "src/content/docs/reference"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fdb72d6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig } from 'vitest/config'; + +// The unit tests run before the build. The site tests read the built pages in dist/. +export default defineConfig({ + test: { + projects: [ + { test: { name: 'unit', include: ['test/unit/**/*.test.ts'] } }, + { test: { name: 'site', include: ['test/site/**/*.test.ts'] } }, + ], + }, +}); From b04c4a595b6b147898873a8802f4ba8e6356954b Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:19:49 +0100 Subject: [PATCH 02/27] test: check every page for accessibility and privacy in a browser The docs site must meet WCAG 2.2 AA, and the privacy page makes promises that a browser can check. axe now checks each page in the light and the dark theme, a page must fit 320 pixels with no sideways scroll, and the first Tab reaches the skip link. The privacy tests fail on a request to another site, on a cookie, and on a stored key that the privacy page does not name. Signed-off-by: MobileMage --- .github/workflows/ci.yml | 1 + package.json | 7 +++-- playwright.config.ts | 25 ++++++++++++++++ pnpm-lock.yaml | 47 ++++++++++++++++++++++++++++++ scripts/check-licences.ts | 5 ++++ src/content/docs/privacy.md | 7 +++++ test/browser/accessibility.spec.ts | 33 +++++++++++++++++++++ test/browser/privacy.spec.ts | 46 +++++++++++++++++++++++++++++ test/browser/site-pages.ts | 16 ++++++++++ 9 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 playwright.config.ts create mode 100644 test/browser/accessibility.spec.ts create mode 100644 test/browser/privacy.spec.ts create mode 100644 test/browser/site-pages.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abaac51..d5da3c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: enable-cache: false - run: corepack enable - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium - run: pnpm check - if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/package.json b/package.json index 819006f..836382b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", "check": "run-s check:source check:site check:policy", "check:source": "run-s format:check lint typecheck test", - "check:site": "run-s build test:site", + "check:site": "run-s build test:site test:browser", "check:policy": "run-s tells prose context:check licences reuse:check workflows:check", "format": "prettier --write .", "format:check": "prettier --check .", @@ -26,7 +26,8 @@ "context:check": "cmp CONTEXT.md spec/CONTEXT.md", "licences": "node scripts/check-licences.ts", "reuse:check": "uvx --with charset-normalizer==3.5.2 reuse@6.2.0 lint", - "workflows:check": "uvx zizmor@1.30.1 --offline .github/workflows" + "workflows:check": "uvx zizmor@1.30.1 --offline .github/workflows", + "test:browser": "playwright test" }, "dependencies": { "@astrojs/starlight": "^0.42.5", @@ -35,10 +36,12 @@ }, "devDependencies": { "@astrojs/check": "^0.9.10", + "@axe-core/playwright": "^4.13.0", "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", "@eslint-community/eslint-plugin-eslint-comments": "^4.8.1", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.63.0", "@types/node": "^24.19.1", "eslint": "^10.11.0", "npm-run-all2": "^9.0.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..05fd66a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 4321; + +// The browser tests read the built site through `astro preview`, which serves it under the base +// path, as GitHub Pages does. Each test runs once in the light theme and once in the dark one. +export default defineConfig({ + testDir: 'test/browser', + fullyParallel: true, + forbidOnly: true, + reporter: 'list', + use: { baseURL: `http://localhost:${String(PORT)}` }, + projects: [ + { name: 'light', use: { ...devices['Desktop Chrome'], colorScheme: 'light' } }, + { name: 'dark', use: { ...devices['Desktop Chrome'], colorScheme: 'dark' } }, + ], + webServer: { + command: `pnpm exec astro preview --port ${String(PORT)} --ignore-lock`, + env: { ASTRO_TELEMETRY_DISABLED: '1' }, + url: `http://localhost:${String(PORT)}/docs/`, + reuseExistingServer: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c80a3cf..54d23a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@astrojs/check': specifier: ^0.9.10 version: 0.9.10(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.9))(prettier@3.9.9)(typescript@6.0.3) + '@axe-core/playwright': + specifier: ^4.13.0 + version: 4.13.0(playwright-core@1.63.0) '@commitlint/cli': specifier: ^21.2.3 version: 21.2.3(@types/node@24.19.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) @@ -191,6 +194,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.11.0(jiti@2.6.1)) + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@types/node': specifier: ^24.19.1 version: 24.19.1 @@ -422,6 +428,11 @@ packages: '@astrojs/yaml2ts@0.2.4': resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + '@axe-core/playwright@4.13.0': + resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1106,6 +1117,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@prettier/parse-srcset@3.1.0': resolution: {integrity: sha512-FIRv2rZotO9NP/r66taYqD1zICpPiBbrECqjKV/xqNotqDxF7fLzzUeK+1RSRx9Tenk0DajR/GcRmTJ2qtHaKQ==} @@ -1497,6 +1513,10 @@ packages: '@astrojs/markdown-remark': optional: true + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2408,6 +2428,16 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss-nested@6.2.0: resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} @@ -3284,6 +3314,11 @@ snapshots: dependencies: yaml: 2.9.1 + '@axe-core/playwright@4.13.0(playwright-core@1.63.0)': + dependencies: + axe-core: 4.13.0 + playwright-core: 1.63.0 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3860,6 +3895,10 @@ snapshots: '@pagefind/windows-x64@1.5.2': optional: true + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@prettier/parse-srcset@3.1.0': {} '@rolldown/binding-android-arm-eabi@1.2.12': @@ -4316,6 +4355,8 @@ snapshots: - uploadthing - yaml + axe-core@4.13.0: {} + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -5349,6 +5390,12 @@ snapshots: pidtree@1.0.0: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss-nested@6.2.0(postcss@8.5.28): dependencies: postcss: 8.5.28 diff --git a/scripts/check-licences.ts b/scripts/check-licences.ts index 00d5b49..2a787d8 100644 --- a/scripts/check-licences.ts +++ b/scripts/check-licences.ts @@ -38,6 +38,11 @@ const EXCEPTIONS: readonly { name: RegExp; licence: string; reason: string }[] = licence: 'MPL-2.0', reason: 'A build tool that Vite runs, unchanged. The site does not ship it.', }, + { + name: /^(?:@axe-core\/playwright|axe-core)$/, + licence: 'MPL-2.0', + reason: 'The accessibility checker of the browser tests, unchanged. The site does not ship it.', + }, ]; /** diff --git a/src/content/docs/privacy.md b/src/content/docs/privacy.md index ae6e5ca..a5c761d 100644 --- a/src/content/docs/privacy.md +++ b/src/content/docs/privacy.md @@ -5,6 +5,13 @@ description: What this site keeps, what it sends and what GitHub logs. This site has no analytics, no tracking and no cookies. Every script, font, image and style comes from this site itself. No page loads anything from another website. +## What your browser keeps + +Your browser keeps two settings of this site, and the site never sends them anywhere. + +- Your choice of a light or a dark theme, in local storage under the name `starlight-theme`. +- Which groups of the side menu you opened, in session storage under the name `sl-sidebar-state`. The browser deletes it when you close the tab. + ## Search The search box runs in your browser. It reads an index that this site built when it was published. Your search text stays in your browser. diff --git a/test/browser/accessibility.spec.ts b/test/browser/accessibility.spec.ts new file mode 100644 index 0000000..909f1c3 --- /dev/null +++ b/test/browser/accessibility.spec.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; +import { sitePaths } from './site-pages.ts'; + +// The tags of the WCAG 2.2 AA success criteria that axe can test (UI-1). +const WCAG_22_AA = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; + +for (const path of [...sitePaths(), '/docs/404.html']) { + test(`${path} has no WCAG 2.2 AA violation that axe finds`, async ({ page }) => { + await page.goto(path); + const results = await new AxeBuilder({ page }).withTags(WCAG_22_AA).analyze(); + expect( + results.violations.map(({ id, nodes }) => `${id}: ${nodes[0]?.target.join(' ') ?? ''}`), + ).toEqual([]); + }); +} + +test('the first Tab moves the focus to the skip link', async ({ page }) => { + await page.goto('/docs/privacy/'); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveText('Skip to content'); +}); + +test('a page fits a screen 320 pixels wide with no sideways scroll', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + for (const path of sitePaths()) { + await page.goto(path); + const width = await page.evaluate(() => document.documentElement.scrollWidth); + expect(width, path).toBeLessThanOrEqual(320); + } +}); diff --git a/test/browser/privacy.spec.ts b/test/browser/privacy.spec.ts new file mode 100644 index 0000000..6d9df18 --- /dev/null +++ b/test/browser/privacy.spec.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { expect, test } from '@playwright/test'; +import { sitePaths } from './site-pages.ts'; + +// The privacy page names each key that the site keeps in the browser, and no other. +const LOCAL_KEYS = ['starlight-theme']; +const SESSION_KEYS = ['sl-sidebar-state']; + +test('no page asks another site for anything', async ({ page, baseURL }) => { + const origins = new Set(); + page.on('request', (request) => origins.add(new URL(request.url()).origin)); + for (const path of sitePaths()) { + await page.goto(path); + } + expect([...origins]).toEqual([baseURL]); +}); + +test('a search sends the text nowhere and keeps no cookie', async ({ page, baseURL }) => { + const origins = new Set(); + page.on('request', (request) => origins.add(new URL(request.url()).origin)); + await page.goto('/docs/privacy/'); + await page.getByRole('button', { name: 'Search' }).click(); + const search = page.getByRole('dialog', { name: 'Search' }); + await search.getByRole('textbox', { name: 'Search' }).fill('postcode'); + await expect(search.getByRole('link').first()).toBeVisible(); + expect([...origins]).toEqual([baseURL]); + expect(await page.context().cookies()).toEqual([]); +}); + +test('the browser keeps only the keys that the privacy page names', async ({ page }) => { + await page.goto('/docs/privacy/'); + await page.getByRole('combobox', { name: 'Select theme' }).first().selectOption('light'); + await page.goto('/docs/'); + const stored = await page.evaluate(() => ({ + local: Object.keys(localStorage), + session: Object.keys(sessionStorage), + })); + expect(stored.local).toContain('starlight-theme'); + expect(stored.local.every((key) => LOCAL_KEYS.includes(key))).toBe(true); + expect(stored.session.every((key) => SESSION_KEYS.includes(key))).toBe(true); + await page.goto('/docs/privacy/'); + for (const key of [...LOCAL_KEYS, ...SESSION_KEYS]) { + await expect(page.getByRole('main')).toContainText(key); + } +}); diff --git a/test/browser/site-pages.ts b/test/browser/site-pages.ts new file mode 100644 index 0000000..9d23a4b --- /dev/null +++ b/test/browser/site-pages.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; + +/** + * Lists the path of each page in the sitemap of the built site, such as `/docs/start/`. + * + * @returns The paths, in the order of the sitemap. + */ +export function sitePaths(): readonly string[] { + const sitemap = readFileSync(new URL('../../dist/sitemap-0.xml', import.meta.url), 'utf8'); + return Array.from( + sitemap.matchAll(/([^<]+)<\/loc>/g), + ([, url]) => new URL(url ?? '').pathname, + ); +} From 860dca87b9e9af047a7aebcdfb5738b84a5a6fd6 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:23:41 +0100 Subject: [PATCH 03/27] feat: add the API reference of the TypeScript packages Each public symbol of the TypeScript packages has a doc comment with an example. TypeDoc now turns those comments into pages of the site, in the same theme and under the same accessibility checks as the guides. A test reads the API report of each package, so a public symbol without a page fails CI. Signed-off-by: MobileMage --- .github/workflows/ci.yml | 1 + .github/workflows/pages.yml | 1 + astro.config.mjs | 26 ++++++- package.json | 10 ++- pnpm-lock.yaml | 140 ++++++++++++++++++++++++++++++++++++ test/site/reference.test.ts | 36 ++++++++++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 test/site/reference.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5da3c1..b9357a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: enable-cache: false - run: corepack enable - run: pnpm install --frozen-lockfile + - run: pnpm sdk - run: pnpm exec playwright install --with-deps chromium - run: pnpm check - if: always() diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9caec3d..c13c047 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -29,6 +29,7 @@ jobs: node-version: 24 - run: corepack enable - run: pnpm install --frozen-lockfile + - run: pnpm sdk - run: pnpm build - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: diff --git a/astro.config.mjs b/astro.config.mjs index f3e2fb8..980e3ac 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,12 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 import starlight from '@astrojs/starlight'; import { defineConfig } from 'astro/config'; +import { createStarlightTypeDocPlugin } from 'starlight-typedoc'; // GitHub Pages serves the org's project site under the repo name. A custom domain changes the // site and sets the base to '/'. const site = 'https://gatepost-dev.github.io'; const base = '/docs'; +const [coreTypeDoc, coreTypeDocGroup] = createStarlightTypeDocPlugin(); +const [clientTypeDoc, clientTypeDocGroup] = createStarlightTypeDocPlugin(); + +// The API pages of a package, generated from the TSDoc comments of its source. +function typeDocOf(name) { + return { + entryPoints: [`js/packages/${name}/src/index.ts`], + tsconfig: `js/packages/${name}/tsconfig.json`, + output: `reference/js/${name}`, + sidebar: { label: `@gatepost/${name}`, collapsed: true }, + typeDoc: { + excludeInternal: true, + readme: 'none', + disableSources: true, + entryFileName: 'index', + }, + }; +} + export default defineConfig({ site, base, @@ -43,7 +63,11 @@ export default defineConfig({ // A code block that scrolls sideways needs a focus stop for keyboard users. Wrapped lines // need none, and they fit a phone screen. expressiveCode: { defaultProps: { wrap: true } }, - sidebar: [{ label: 'Project', items: ['privacy'] }], + plugins: [coreTypeDoc(typeDocOf('core')), clientTypeDoc(typeDocOf('client'))], + sidebar: [ + { label: 'API reference', items: [coreTypeDocGroup, clientTypeDocGroup] }, + { label: 'Project', items: ['privacy'] }, + ], }), ], }); diff --git a/package.json b/package.json index 836382b..8d4fcd3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "node": ">=24.15.0" }, "scripts": { + "sdk": "run-s sdk:js sdk:js:build", + "sdk:js": "pnpm --dir js install --frozen-lockfile", + "sdk:js:build": "pnpm --dir js --filter @gatepost/core --filter @gatepost/client run build", "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev", "build": "ASTRO_TELEMETRY_DISABLED=1 astro build", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", @@ -32,7 +35,12 @@ "dependencies": { "@astrojs/starlight": "^0.42.5", "@fontsource/overpass": "^5.3.0", - "astro": "^7.3.5" + "@gatepost/client": "link:js/packages/client", + "@gatepost/core": "link:js/packages/core", + "astro": "^7.3.5", + "starlight-typedoc": "^0.23.1", + "typedoc": "^0.28.20", + "typedoc-plugin-markdown": "^4.13.1" }, "devDependencies": { "@astrojs/check": "^0.9.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d23a3..92dad7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,9 +172,24 @@ importers: '@fontsource/overpass': specifier: ^5.3.0 version: 5.3.0 + '@gatepost/client': + specifier: link:js/packages/client + version: link:js/packages/client + '@gatepost/core': + specifier: link:js/packages/core + version: link:js/packages/core astro: specifier: ^7.3.5 version: 7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1) + starlight-typedoc: + specifier: ^0.23.1 + version: 0.23.1(@astrojs/starlight@0.42.5(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typescript@6.0.3))(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typedoc-plugin-markdown@4.13.1(typedoc@0.28.20(typescript@6.0.3)))(typedoc@0.28.20(typescript@6.0.3)) + typedoc: + specifier: ^0.28.20 + version: 0.28.20(typescript@6.0.3) + typedoc-plugin-markdown: + specifier: ^4.13.1 + version: 4.13.1(typedoc@0.28.20(typescript@6.0.3)) devDependencies: '@astrojs/check': specifier: ^0.9.10 @@ -865,6 +880,9 @@ packages: '@fontsource/overpass@5.3.0': resolution: {integrity: sha512-601DJ9xb8+8XbxAS05+lkh9cW6wc4TnXL2vhQBUN6PrtE/mQTgxqrqac7rXkthyJO7c3KMHOpl9Tt0sRql1p5Q==} + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1232,10 +1250,16 @@ packages: resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==} engines: {node: '>=20'} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/engine-oniguruma@4.5.0': resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==} engines: {node: '>=20'} + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/langs@4.5.0': resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==} engines: {node: '>=20'} @@ -1244,10 +1268,16 @@ packages: resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==} engines: {node: '>=20'} + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/themes@4.5.0': resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==} engines: {node: '>=20'} + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/types@4.5.0': resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} engines: {node: '>=20'} @@ -2171,6 +2201,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2182,12 +2215,19 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + markdown-it@14.3.2: + resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==} + hasBin: true + mdast-util-directive@3.1.0: resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} @@ -2212,6 +2252,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -2478,6 +2521,10 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2608,6 +2655,15 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + starlight-typedoc@0.23.1: + resolution: {integrity: sha512-H45Mxv4mZ1xUMhUNjTR4CwKDgKKHZboQA5v5+vTVMdjlCX02Xo7veTB7+F/ivmMHPmQ7SV5auF2BTlU/EpQIoQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@astrojs/starlight': '>=0.39.0' + astro: '>=6.0.0' + typedoc: '>=0.28.0' + typedoc-plugin-markdown: '>=4.6.0' + std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} @@ -2672,6 +2728,19 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typedoc-plugin-markdown@4.13.1: + resolution: {integrity: sha512-d3jchJ4CyIrV9w9uozTSkfMYG8IEM1RvvGOaybJ/3rRporzoX7p0lprffo95GOGUviQqP0phjHJE3bY8PQbilA==} + engines: {node: '>= 18'} + peerDependencies: + typedoc: 0.28.x + + typedoc@0.28.20: + resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typesafe-path@0.2.2: resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} @@ -2690,6 +2759,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -3715,6 +3787,14 @@ snapshots: '@fontsource/overpass@5.3.0': {} + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3962,11 +4042,20 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.5.0': dependencies: '@shikijs/types': 4.5.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/langs@4.5.0': dependencies: '@shikijs/types': 4.5.0 @@ -3977,10 +4066,19 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/themes@4.5.0': dependencies: '@shikijs/types': 4.5.0 + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + '@shikijs/types@4.5.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -5011,6 +5109,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5019,6 +5121,8 @@ snapshots: lru-cache@11.5.3: {} + lunr@2.3.9: {} + magic-string@1.4.2: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5029,6 +5133,15 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.2 + markdown-it@14.3.2: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 @@ -5099,6 +5212,8 @@ snapshots: mdn-data@2.27.1: {} + mdurl@2.1.0: {} + memorystream@0.3.1: {} micromark-core-commonmark@2.0.4: @@ -5432,6 +5547,8 @@ snapshots: property-information@7.2.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} qified@0.10.1: @@ -5606,6 +5723,14 @@ snapshots: space-separated-tokens@2.0.2: {} + starlight-typedoc@0.23.1(@astrojs/starlight@0.42.5(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typescript@6.0.3))(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typedoc-plugin-markdown@4.13.1(typedoc@0.28.20(typescript@6.0.3)))(typedoc@0.28.20(typescript@6.0.3)): + dependencies: + '@astrojs/starlight': 0.42.5(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typescript@6.0.3) + astro: 7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1) + github-slugger: 2.0.0 + typedoc: 0.28.20(typescript@6.0.3) + typedoc-plugin-markdown: 4.13.1(typedoc@0.28.20(typescript@6.0.3)) + std-env@4.3.0: {} string-width@7.2.0: @@ -5669,6 +5794,19 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typedoc-plugin-markdown@4.13.1(typedoc@0.28.20(typescript@6.0.3)): + dependencies: + typedoc: 0.28.20(typescript@6.0.3) + + typedoc@0.28.20(typescript@6.0.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.2 + minimatch: 10.2.6 + typescript: 6.0.3 + yaml: 2.9.1 + typesafe-path@0.2.2: {} typescript-auto-import-cache@0.3.6: @@ -5688,6 +5826,8 @@ snapshots: typescript@6.0.3: {} + uc.micro@2.1.0: {} + ufo@1.6.4: {} ultrahtml@1.7.0: {} diff --git a/test/site/reference.test.ts b/test/site/reference.test.ts new file mode 100644 index 0000000..5c87c9d --- /dev/null +++ b/test/site/reference.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { DIST } from './built-site.ts'; + +// The folder of each kind of export, as the TypeDoc pages name it. +const FOLDERS: Readonly> = { + function: 'functions', + interface: 'interfaces', + type: 'type-aliases', + const: 'variables', + class: 'classes', +}; + +// The API report of a package lists each public symbol, so the reference must have a page for +// each line that starts with `export`. +function exportsOf(name: string): readonly (readonly [string, string])[] { + const report = readFileSync(`js/packages/${name}/etc/${name}.api.md`, 'utf8'); + return Array.from( + report.matchAll(/^export (function|interface|type|const|class) (\w+)/gm), + ([, kind, symbol]) => [FOLDERS[kind ?? ''] ?? '', symbol ?? ''], + ); +} + +describe.each(['core', 'client'])('the API reference of @gatepost/%s', (name) => { + it('has a page for each symbol of the API report', () => { + const missing = exportsOf(name) + .map( + ([folder, symbol]) => `reference/js/${name}/${folder}/${symbol.toLowerCase()}/index.html`, + ) + .filter((path) => !existsSync(join(DIST, path))); + expect(missing).toEqual([]); + }); +}); From 5e504319ae900106f4d0ca0779b636040b5834f7 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:26:50 +0100 Subject: [PATCH 04/27] feat: show the grammar, the client contract and the glossary The spec ranks above the standards and the code, so readers need it in the docs. The build copies grammar.md, client.md and the glossary from the spec submodule, with a link to each file at the pinned commit. A test fails when an SDK of the site implements another spec version. Signed-off-by: MobileMage --- astro.config.mjs | 10 +++++ scripts/spec-pages.ts | 76 ++++++++++++++++++++++++++++++++++++ test/unit/spec-pages.test.ts | 41 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 scripts/spec-pages.ts create mode 100644 test/unit/spec-pages.test.ts diff --git a/astro.config.mjs b/astro.config.mjs index 980e3ac..1a6f299 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,6 +3,7 @@ import starlight from '@astrojs/starlight'; import { defineConfig } from 'astro/config'; import { createStarlightTypeDocPlugin } from 'starlight-typedoc'; +import { writeSpecPages } from './scripts/spec-pages.ts'; // GitHub Pages serves the org's project site under the repo name. A custom domain changes the // site and sets the base to '/'. @@ -33,6 +34,14 @@ export default defineConfig({ base, trailingSlash: 'always', integrations: [ + { + name: 'gatepost-generated-pages', + hooks: { + 'astro:config:setup': () => { + writeSpecPages(import.meta.dirname); + }, + }, + }, starlight({ title: 'Gatepost', description: "Unofficial developer tools for Nigeria's digital postcode.", @@ -65,6 +74,7 @@ export default defineConfig({ expressiveCode: { defaultProps: { wrap: true } }, plugins: [coreTypeDoc(typeDocOf('core')), clientTypeDoc(typeDocOf('client'))], sidebar: [ + { label: 'Spec', items: [{ autogenerate: { directory: 'spec' } }] }, { label: 'API reference', items: [coreTypeDocGroup, clientTypeDocGroup] }, { label: 'Project', items: ['privacy'] }, ], diff --git a/scripts/spec-pages.ts b/scripts/spec-pages.ts new file mode 100644 index 0000000..e2765c4 --- /dev/null +++ b/scripts/spec-pages.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Writes the spec pages of the site from the spec submodule, so that the site shows the grammar, +// the client contract and the glossary that the SDKs implement, word for word. +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** A page that the site builds from one file of the spec. */ +export interface SpecPage { + readonly slug: string; + readonly text: string; +} + +const SOURCES = [ + { + file: 'grammar.md', + slug: 'grammar', + title: 'Postcode grammar', + description: 'How every Gatepost SDK reads and writes postcodes, and the core interface.', + }, + { + file: 'client.md', + slug: 'client', + title: 'Client contract', + description: "How every Gatepost client calls NIPOST's gateway: results, errors and retries.", + }, + { + file: 'CONTEXT.md', + slug: 'glossary', + title: 'Glossary', + description: 'One name for each concept, in every language and every repo.', + }, +] as const; + +/** + * Builds the spec pages from the files of a spec folder. + * + * @param specDir - The spec folder, such as the `spec` submodule. + * @param commit - The commit of the spec folder, for the link to the source. + * @returns One page for each spec file, with its front matter. + */ +export function specPages(specDir: string, commit: string): readonly SpecPage[] { + const version = readFileSync(join(specDir, 'VERSION'), 'utf8').trim(); + return SOURCES.map(({ file, slug, title, description }, index) => { + const source = readFileSync(join(specDir, file), 'utf8'); + // The site shows the title above the page, so the first heading of the file goes. + const body = source.replace(/^# .*\n+/, ''); + const url = `https://github.com/gatepost-dev/spec/blob/${commit}/${file}`; + const frontMatter = [ + '---', + `title: ${title}`, + `description: "${description}"`, + 'editUrl: false', + `sidebar: { order: ${String(index + 1)} }`, + '---', + ].join('\n'); + const note = `:::note\nThis page shows [\`${file}\`](${url}) from spec ${version}.\n:::`; + return { slug, text: `${frontMatter}\n\n${note}\n\n${body}` }; + }); +} + +/** + * Writes the spec pages into the docs collection. The build calls it before it reads the pages. + * + * @param root - The root folder of the docs repo. + */ +export function writeSpecPages(root: string): void { + const specDir = join(root, 'spec'); + const commit = execFileSync('git', ['-C', specDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); + const folder = join(root, 'src/content/docs/spec'); + mkdirSync(folder, { recursive: true }); + for (const { slug, text } of specPages(specDir, commit.trim())) { + writeFileSync(join(folder, `${slug}.md`), text); + } +} diff --git a/test/unit/spec-pages.test.ts b/test/unit/spec-pages.test.ts new file mode 100644 index 0000000..a63dcc9 --- /dev/null +++ b/test/unit/spec-pages.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { SPEC_VERSION as CLIENT_SPEC_VERSION } from '@gatepost/client'; +import { SPEC_VERSION as CORE_SPEC_VERSION } from '@gatepost/core'; +import { describe, expect, it } from 'vitest'; +import { specPages } from '../../scripts/spec-pages.ts'; + +const SPEC_VERSION = readFileSync('spec/VERSION', 'utf8').trim(); +const PAGES = specPages('spec', 'abc123'); + +describe('specPages', () => { + it('builds the grammar, the client contract and the glossary', () => { + expect(PAGES.map(({ slug }) => slug)).toEqual(['grammar', 'client', 'glossary']); + }); + + it('gives each page a title and drops the first heading of its file', () => { + const grammar = PAGES[0]?.text ?? ''; + expect(grammar).toMatch(/^---\ntitle: Postcode grammar\n/); + expect(grammar).not.toContain('\n# Postcode grammar\n'); + expect(grammar).toContain('\n## Segments\n'); + }); + + it('links each page to its file at the commit of the submodule, and names the version', () => { + expect(PAGES[1]?.text).toContain( + `[\`client.md\`](https://github.com/gatepost-dev/spec/blob/abc123/client.md) from spec ${SPEC_VERSION}.`, + ); + }); +}); + +describe('the spec that the site shows', () => { + it('is the spec that each SDK of the site implements', () => { + const php = readFileSync('php/src/Postcode.php', 'utf8'); + const phpVersion = /SPEC_VERSION = '([^']+)'/.exec(php)?.[1]; + expect([CORE_SPEC_VERSION, CLIENT_SPEC_VERSION, phpVersion]).toEqual([ + SPEC_VERSION, + SPEC_VERSION, + SPEC_VERSION, + ]); + }); +}); From accfd6fb4cafb484d9a7ad88de505d6d33d8e4f3 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:32:54 +0100 Subject: [PATCH 05/27] test: scan inline styles and scripts, and guard the Pages deploy The no-third-party check read only the fetching attributes and the built style sheets. An inline style element, a style attribute or a script could still name another host. The check now reads those too, and it reads the built script files. Pagefind keeps a dummy base host and translator credit links in its scripts, so the check ignores those two patterns and the XML namespaces. The deploy job also runs only on main, because a manual run may start from any branch. A test fails when the workflow gains a trigger other than a push to main or a manual run, or when a checkout keeps its credentials. The theme comment now names the test that checks contrast. Signed-off-by: MobileMage --- .github/workflows/pages.yml | 2 + package.json | 3 +- pnpm-lock.yaml | 3 ++ src/styles/theme.css | 4 +- test/site/external-urls.ts | 75 ++++++++++++++++++++++++++++++++ test/site/pages.test.ts | 34 +++++---------- test/unit/external-urls.test.ts | 65 +++++++++++++++++++++++++++ test/unit/pages-workflow.test.ts | 42 ++++++++++++++++++ test/unit/pages-workflow.ts | 55 +++++++++++++++++++++++ 9 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 test/site/external-urls.ts create mode 100644 test/unit/external-urls.test.ts create mode 100644 test/unit/pages-workflow.test.ts create mode 100644 test/unit/pages-workflow.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c13c047..9f240c2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -37,6 +37,8 @@ jobs: deploy: needs: build + # A manual run may start from any branch. Only main may publish. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: diff --git a/package.json b/package.json index 8d4fcd3..565970a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "prettier-plugin-astro": "^1.1.0", "typescript": "6.0.3", "typescript-eslint": "^8.71.0", - "vitest": "^5.0.3" + "vitest": "^5.0.3", + "yaml": "^2.9.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dad7e..e2cfbb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: vitest: specifier: ^5.0.3 version: 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + yaml: + specifier: ^2.9.1 + version: 2.9.1 packages: diff --git a/src/styles/theme.css b/src/styles/theme.css index c066577..35b51f9 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3,8 +3,8 @@ /* * The theme tokens of the site (UI-3). The colours come from the brand: near-black #0E1513, white * and teal #0B8574. Teal text needs a lighter tone on the dark theme and a darker one on the - * light theme to reach 4.5:1, so each theme sets its own text accent. test/theme.test.ts checks - * the contrast of each pair that the site uses. + * light theme to reach 4.5:1, so each theme sets its own text accent. + * test/browser/accessibility.spec.ts checks the contrast of each pair that the site uses. */ :root { --sl-font: 'Overpass'; diff --git a/test/site/external-urls.ts b/test/site/external-urls.ts new file mode 100644 index 0000000..5896ee8 --- /dev/null +++ b/test/site/external-urls.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +// Each attribute that makes the browser fetch something when the page loads. An only +// fetches when the reader follows it, so it may point at another site. So may a that +// names the page itself or the sitemap, because the browser does not fetch those. +const FETCHING_TAG = /<(script|link|img|source|iframe|video|audio|embed|object|track)\b[^>]*>/gi; +const NAMING_LINK = /^]*\srel="(?:canonical|alternate|sitemap)"/i; +const FETCHING_ATTRIBUTE = /\s(?:src|href|srcset|poster|data)="([^"]*)"/gi; +const STYLE_BODY = /]*>([\s\S]*?)<\/style>/gi; +const STYLE_ATTRIBUTE = /\sstyle="([^"]*)"/gi; +const SCRIPT_BODY = /]*>([\s\S]*?)<\/script>/gi; + +// An XML namespace names a kind of document. The browser never fetches it. +const NAMESPACE = /^https?:\/\/www\.w3\.org\//; + +// Pagefind builds a URL against a dummy host to parse a path, and it never fetches the host. Its +// translation files also credit each translator as `Name `, which is text. +const DUMMY_BASE = /^https:\/\/(?:p|example\.com(?:\$\{.*)?)$/; +const CREDIT_LINK = />$/; + +// A URL that the site serves itself: a path, a relative URL, a fragment or inline data. +export function isOwnUrl(url: string): boolean { + return !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(url) || url.startsWith('data:'); +} + +function matches(text: string, pattern: RegExp): readonly string[] { + return Array.from(text.matchAll(pattern), ([, group]) => group ?? ''); +} + +/** + * Lists the URLs in a style sheet that point at another site. + * + * @param css - The text of a style sheet, a `

', + '', + ].join(''), + ); + expect(foreignPageUrls(html)).toEqual([]); + }); + + it('reports an @import in an inline style element', () => { + const html = page(''); + expect(foreignPageUrls(html)).toEqual(['https://fonts.googleapis.com/css']); + }); + + it('reports a url() in a style attribute, also when the quotes are escaped', () => { + const plain = page('
'); + const escaped = page('
'); + expect(foreignPageUrls(plain)).toEqual(['https://example.com/x.png']); + expect(foreignPageUrls(escaped)).toEqual(['//example.com/y.png']); + }); + + it('reports a host in an inline script', () => { + const html = page(''); + expect(foreignPageUrls(html)).toEqual(['https://api.postcode.gov.ng/x']); + }); + + it('still reports a script file, an image and a srcset on another site', () => { + const html = page( + '', + ); + expect(foreignPageUrls(html)).toEqual(['//cdn.example.com/a.js', 'https://x.example/b.png']); + }); +}); + +describe('foreignScriptUrls', () => { + it('reports an absolute and a protocol-relative URL in a script file', () => { + expect(foreignScriptUrls('fetch(`https://a.example/x`);import("//b.example/y.js")')).toEqual([ + 'https://a.example/x', + '//b.example/y.js', + ]); + }); + + it('ignores a comment slash pair and a regular expression', () => { + expect(foreignScriptUrls('const a = 1; // note\nconst b = /a\\/\\/b/;')).toEqual([]); + }); +}); + +describe('foreignCssUrls', () => { + it('reports url() and @import to another site, and accepts own and inline data', () => { + const css = "@import 'https://a.example/a.css';a{background:url(data:image/png;base64,AA)}"; + expect(foreignCssUrls(css)).toEqual(['https://a.example/a.css']); + expect(foreignCssUrls('a{background:url(/docs/a.png)}')).toEqual([]); + }); +}); diff --git a/test/unit/pages-workflow.test.ts b/test/unit/pages-workflow.test.ts new file mode 100644 index 0000000..b4fc4b8 --- /dev/null +++ b/test/unit/pages-workflow.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { pagesProblems } from './pages-workflow.ts'; + +const REAL = readFileSync(new URL('../../.github/workflows/pages.yml', import.meta.url), 'utf8'); + +describe('pages.yml', () => { + it('follows the publishing rules', () => { + expect(pagesProblems(REAL)).toEqual([]); + }); + + it('fails when a pull request can start it', () => { + const text = REAL.replace(' workflow_dispatch:', ' workflow_dispatch:\n pull_request:'); + expect(pagesProblems(text)).toEqual([ + 'The triggers are pull_request, push, workflow_dispatch. ' + + 'Use push and workflow_dispatch only.', + ]); + }); + + it('fails when the push trigger names another branch', () => { + const text = REAL.replace('branches: [main]', 'branches: [main, next]'); + expect(pagesProblems(text)).toEqual([ + 'The push trigger must name the branch main and no other.', + ]); + }); + + it('fails when the deploy job loses its guard', () => { + const text = REAL.replace(/ {4}if: .*\n/, ''); + expect(pagesProblems(text)).toEqual([ + "The deploy job needs the guard if: github.ref == 'refs/heads/main'.", + ]); + }); + + it('fails when a checkout keeps its credentials', () => { + const text = REAL.replace('persist-credentials: false', 'persist-credentials: true'); + expect(pagesProblems(text)).toEqual([ + 'The checkout in build must set persist-credentials to false.', + ]); + }); +}); diff --git a/test/unit/pages-workflow.ts b/test/unit/pages-workflow.ts new file mode 100644 index 0000000..a4507f3 --- /dev/null +++ b/test/unit/pages-workflow.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { parse } from 'yaml'; + +interface Step { + readonly uses?: string; + readonly with?: Readonly>; +} + +interface Workflow { + readonly on?: Readonly>; + readonly jobs?: Readonly< + Record + >; +} + +const MAIN_ONLY = "github.ref == 'refs/heads/main'"; + +function triggerProblems(workflow: Workflow): readonly string[] { + const triggers = Object.keys(workflow.on ?? {}).sort(); + const problems: string[] = []; + if (triggers.join() !== 'push,workflow_dispatch') { + problems.push(`The triggers are ${triggers.join(', ')}. Use push and workflow_dispatch only.`); + } + const push = workflow.on?.['push'] as { readonly branches?: readonly string[] } | null; + if (push?.branches?.join() !== 'main') { + problems.push('The push trigger must name the branch main and no other.'); + } + return problems; +} + +function checkoutProblems(workflow: Workflow): readonly string[] { + return Object.entries(workflow.jobs ?? {}).flatMap(([name, job]) => + (job?.steps ?? []) + .filter((step) => step.uses?.startsWith('actions/checkout@') === true) + .filter((step) => step.with?.['persist-credentials'] !== false) + .map(() => `The checkout in ${name} must set persist-credentials to false.`), + ); +} + +/** + * Checks the rules that keep the publishing workflow safe. Only a push to main and a manual run + * may start it, the deploy job runs only on main, and no checkout leaves a token in the clone. + * + * @param text - The YAML text of the workflow. + * @returns One line for each rule that the workflow breaks. + */ +export function pagesProblems(text: string): readonly string[] { + const workflow = parse(text) as Workflow; + const guard = + workflow.jobs?.['deploy']?.if === MAIN_ONLY + ? [] + : [`The deploy job needs the guard if: ${MAIN_ONLY}.`]; + return [...triggerProblems(workflow), ...guard, ...checkoutProblems(workflow)]; +} From c3d181fed8d893b9fbb548a18f2f3f37fbd7fb5c Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:34:57 +0100 Subject: [PATCH 06/27] test: check stored keys on every page and every export kind The stored-key test visited two pages, so a script on any other page could keep an unnamed key in the browser. It now runs on every page of the sitemap and on the 404 page. The API reference test read only five kinds of export. An enum, an abstract class or a namespace had no page and no failure, and an empty report passed. The reader now knows those kinds, fails on any other line that starts with export, and fails on a report with no export. The privacy page now says that the side menu key also keeps the scroll position, as Starlight stores it. Signed-off-by: MobileMage --- src/content/docs/privacy.md | 2 +- test/browser/privacy.spec.ts | 28 ++++++++++++-------- test/site/api-report.ts | 50 ++++++++++++++++++++++++++++++++++++ test/site/reference.test.ts | 22 ++++------------ test/unit/api-report.test.ts | 39 ++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 29 deletions(-) create mode 100644 test/site/api-report.ts create mode 100644 test/unit/api-report.test.ts diff --git a/src/content/docs/privacy.md b/src/content/docs/privacy.md index a5c761d..14b78de 100644 --- a/src/content/docs/privacy.md +++ b/src/content/docs/privacy.md @@ -10,7 +10,7 @@ This site has no analytics, no tracking and no cookies. Every script, font, imag Your browser keeps two settings of this site, and the site never sends them anywhere. - Your choice of a light or a dark theme, in local storage under the name `starlight-theme`. -- Which groups of the side menu you opened, in session storage under the name `sl-sidebar-state`. The browser deletes it when you close the tab. +- Which groups of the side menu you opened, and how far the menu has scrolled, in session storage under the name `sl-sidebar-state`. The browser deletes it when you close the tab. ## Search diff --git a/test/browser/privacy.spec.ts b/test/browser/privacy.spec.ts index 6d9df18..a9af665 100644 --- a/test/browser/privacy.spec.ts +++ b/test/browser/privacy.spec.ts @@ -28,17 +28,23 @@ test('a search sends the text nowhere and keeps no cookie', async ({ page, baseU expect(await page.context().cookies()).toEqual([]); }); -test('the browser keeps only the keys that the privacy page names', async ({ page }) => { - await page.goto('/docs/privacy/'); - await page.getByRole('combobox', { name: 'Select theme' }).first().selectOption('light'); - await page.goto('/docs/'); - const stored = await page.evaluate(() => ({ - local: Object.keys(localStorage), - session: Object.keys(sessionStorage), - })); - expect(stored.local).toContain('starlight-theme'); - expect(stored.local.every((key) => LOCAL_KEYS.includes(key))).toBe(true); - expect(stored.session.every((key) => SESSION_KEYS.includes(key))).toBe(true); +// Every page, the 404 page included, because a script on any one of them could store a key. +for (const path of [...sitePaths(), '/docs/no-such-page/']) { + test(`${path} keeps only the keys that the privacy page names`, async ({ page }) => { + await page.goto('/docs/privacy/'); + await page.getByRole('combobox', { name: 'Select theme' }).first().selectOption('light'); + await page.goto(path); + const stored = await page.evaluate(() => ({ + local: Object.keys(localStorage), + session: Object.keys(sessionStorage), + })); + expect(stored.local).toContain('starlight-theme'); + expect(stored.local.filter((key) => !LOCAL_KEYS.includes(key))).toEqual([]); + expect(stored.session.filter((key) => !SESSION_KEYS.includes(key))).toEqual([]); + }); +} + +test('the privacy page names each key', async ({ page }) => { await page.goto('/docs/privacy/'); for (const key of [...LOCAL_KEYS, ...SESSION_KEYS]) { await expect(page.getByRole('main')).toContainText(key); diff --git a/test/site/api-report.ts b/test/site/api-report.ts new file mode 100644 index 0000000..8ce663e --- /dev/null +++ b/test/site/api-report.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 + +/** One public symbol of an API report, with the folder that TypeDoc gives its page. */ +export interface ReportedExport { + readonly folder: string; + readonly symbol: string; +} + +// The folder of each kind of export, as the TypeDoc pages name it. +const FOLDERS: Readonly> = { + function: 'functions', + interface: 'interfaces', + type: 'type-aliases', + const: 'variables', + class: 'classes', + 'abstract class': 'classes', + enum: 'enums', + 'const enum': 'enums', + namespace: 'namespaces', +}; + +const KINDS = Object.keys(FOLDERS) + .sort((a, b) => b.length - a.length) + .join('|'); +const EXPORT_LINE = new RegExp(`^export (?:declare )?(${KINDS}) (\\w+)`); + +/** + * Lists the public symbols of an API report. Every line that starts with `export` must be one + * of the known kinds, so a new kind of export fails here instead of passing without a page. + * + * @param report - The text of the `.api.md` file that API Extractor writes. + * @returns The symbols, in the order of the report. + * @throws When the report holds no export, or a line starts with `export` and is no known kind. + */ +export function exportsOf(report: string): readonly ReportedExport[] { + const lines = report.split('\n').filter((line) => line.startsWith('export ')); + const found: ReportedExport[] = []; + const unknown: string[] = []; + for (const line of lines) { + const [, kind, symbol] = EXPORT_LINE.exec(line) ?? []; + if (kind === undefined || symbol === undefined) unknown.push(line); + else found.push({ folder: FOLDERS[kind] ?? '', symbol }); + } + if (unknown.length > 0) { + throw new Error(`The test does not know these exports: ${unknown.join(' | ')}`); + } + if (found.length === 0) throw new Error('The API report holds no export.'); + return found; +} diff --git a/test/site/reference.test.ts b/test/site/reference.test.ts index 5c87c9d..d79f850 100644 --- a/test/site/reference.test.ts +++ b/test/site/reference.test.ts @@ -3,32 +3,20 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { exportsOf, type ReportedExport } from './api-report.ts'; import { DIST } from './built-site.ts'; -// The folder of each kind of export, as the TypeDoc pages name it. -const FOLDERS: Readonly> = { - function: 'functions', - interface: 'interfaces', - type: 'type-aliases', - const: 'variables', - class: 'classes', -}; - // The API report of a package lists each public symbol, so the reference must have a page for // each line that starts with `export`. -function exportsOf(name: string): readonly (readonly [string, string])[] { - const report = readFileSync(`js/packages/${name}/etc/${name}.api.md`, 'utf8'); - return Array.from( - report.matchAll(/^export (function|interface|type|const|class) (\w+)/gm), - ([, kind, symbol]) => [FOLDERS[kind ?? ''] ?? '', symbol ?? ''], - ); +function reported(name: string): readonly ReportedExport[] { + return exportsOf(readFileSync(`js/packages/${name}/etc/${name}.api.md`, 'utf8')); } describe.each(['core', 'client'])('the API reference of @gatepost/%s', (name) => { it('has a page for each symbol of the API report', () => { - const missing = exportsOf(name) + const missing = reported(name) .map( - ([folder, symbol]) => `reference/js/${name}/${folder}/${symbol.toLowerCase()}/index.html`, + ({ folder, symbol }) => `reference/js/${name}/${folder}/${symbol.toLowerCase()}/index.html`, ) .filter((path) => !existsSync(join(DIST, path))); expect(missing).toEqual([]); diff --git a/test/unit/api-report.test.ts b/test/unit/api-report.test.ts new file mode 100644 index 0000000..de6556b --- /dev/null +++ b/test/unit/api-report.test.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { exportsOf } from '../site/api-report.ts'; + +describe('exportsOf', () => { + it('reads each kind of export that TypeDoc gives a page', () => { + const report = [ + 'export function parse(input: string): Postcode;', + 'export interface Segments {}', + 'export type Precision = 1 | 2;', + 'export const SPEC_VERSION = "0.2.0";', + 'export class PostcodeError extends Error {}', + 'export abstract class Base {}', + 'export enum Level {}', + 'export namespace Tools {}', + ].join('\n'); + expect(exportsOf(report)).toEqual([ + { folder: 'functions', symbol: 'parse' }, + { folder: 'interfaces', symbol: 'Segments' }, + { folder: 'type-aliases', symbol: 'Precision' }, + { folder: 'variables', symbol: 'SPEC_VERSION' }, + { folder: 'classes', symbol: 'PostcodeError' }, + { folder: 'classes', symbol: 'Base' }, + { folder: 'enums', symbol: 'Level' }, + { folder: 'namespaces', symbol: 'Tools' }, + ]); + }); + + it('fails on an empty report', () => { + expect(() => exportsOf('# API report\n')).toThrow('The API report holds no export.'); + }); + + it('fails on a line that starts with export and is no known kind', () => { + expect(() => exportsOf('export function a(): void;\nexport { b } from "./b";')).toThrow( + 'The test does not know these exports: export { b } from "./b";', + ); + }); +}); From 91980bc5657cb90de40d4b584617ffeb17809dc5 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:37:59 +0100 Subject: [PATCH 07/27] fix: give a wide table a focus stop and run axe at 320 pixels On a narrow screen the tables of the grammar and the client contract scroll sideways inside their own box. A keyboard user could not reach that box. The spec pages now put each table in a labelled region that takes the focus, and the style sheet lets that box scroll instead of the table. The axe run used the desktop width only, so it could not see the fault. It now also runs at 320 pixels on every page. Signed-off-by: MobileMage --- scripts/spec-pages.ts | 30 +++++++++++++++++++- src/styles/theme.css | 18 ++++++++++++ test/browser/accessibility.spec.ts | 13 +++++++++ test/unit/spec-pages.test.ts | 45 +++++++++++++++++++++++++++++- 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/scripts/spec-pages.ts b/scripts/spec-pages.ts index e2765c4..9fcd76f 100644 --- a/scripts/spec-pages.ts +++ b/scripts/spec-pages.ts @@ -33,6 +33,34 @@ const SOURCES = [ }, ] as const; +/** + * Puts each Markdown table in a box that scrolls and takes the keyboard focus. A wide table + * scrolls sideways on a narrow screen, and a keyboard user cannot reach it without a focus stop + * (WCAG 2.1.1). The label of the box names the heading above the table. + * + * @param markdown - The Markdown of a page. It must hold no code fence with a table in it. + * @returns The Markdown with each table inside an HTML box. + */ +export function wrapTables(markdown: string): string { + const out: string[] = []; + let heading = ''; + let inTable = false; + for (const line of markdown.split('\n')) { + const isRow = line.startsWith('|'); + if (isRow && !inTable) { + const label = heading === '' ? 'Table' : `Table: ${heading}`; + const box = `
`; + out.push(box, ''); + } else if (!isRow && inTable) { + out.push('', '
'); + } + inTable = isRow; + heading = /^#{1,6} /.test(line) ? line.replace(/^#+ /, '').trim() : heading; + out.push(line); + } + return out.join('\n'); +} + /** * Builds the spec pages from the files of a spec folder. * @@ -56,7 +84,7 @@ export function specPages(specDir: string, commit: string): readonly SpecPage[] '---', ].join('\n'); const note = `:::note\nThis page shows [\`${file}\`](${url}) from spec ${version}.\n:::`; - return { slug, text: `${frontMatter}\n\n${note}\n\n${body}` }; + return { slug, text: `${frontMatter}\n\n${note}\n\n${wrapTables(body)}` }; }); } diff --git a/src/styles/theme.css b/src/styles/theme.css index 35b51f9..6f4b42a 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -38,6 +38,24 @@ --sl-color-text-accent: var(--sl-color-accent-high); } +/* + * A table scrolls inside its own box, which takes the focus (see rehype-scrollable-tables). The + * table itself must not scroll, or the box would have nothing to scroll. + */ +.table-scroll { + overflow-x: auto; +} + +.table-scroll:focus-visible { + outline: 2px solid var(--sl-color-accent); + outline-offset: 2px; +} + +.sl-markdown-content .table-scroll > table { + display: table; + overflow: visible; +} + h1, h2, h3, diff --git a/test/browser/accessibility.spec.ts b/test/browser/accessibility.spec.ts index 909f1c3..94f0d46 100644 --- a/test/browser/accessibility.spec.ts +++ b/test/browser/accessibility.spec.ts @@ -31,3 +31,16 @@ test('a page fits a screen 320 pixels wide with no sideways scroll', async ({ pa expect(width, path).toBeLessThanOrEqual(320); } }); + +// A wide table scrolls inside its own box on a narrow screen. The box needs a focus stop, or a +// keyboard user cannot scroll it (WCAG 2.1.1). The desktop run above never sees this. +for (const path of [...sitePaths(), '/docs/404.html']) { + test(`${path} has no violation that axe finds at 320 pixels`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + await page.goto(path); + const results = await new AxeBuilder({ page }).withTags(WCAG_22_AA).analyze(); + expect( + results.violations.map(({ id, nodes }) => `${id}: ${nodes[0]?.target.join(' ') ?? ''}`), + ).toEqual([]); + }); +} diff --git a/test/unit/spec-pages.test.ts b/test/unit/spec-pages.test.ts index a63dcc9..bf5518c 100644 --- a/test/unit/spec-pages.test.ts +++ b/test/unit/spec-pages.test.ts @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { SPEC_VERSION as CLIENT_SPEC_VERSION } from '@gatepost/client'; import { SPEC_VERSION as CORE_SPEC_VERSION } from '@gatepost/core'; import { describe, expect, it } from 'vitest'; -import { specPages } from '../../scripts/spec-pages.ts'; +import { specPages, wrapTables } from '../../scripts/spec-pages.ts'; const SPEC_VERSION = readFileSync('spec/VERSION', 'utf8').trim(); const PAGES = specPages('spec', 'abc123'); @@ -28,6 +28,49 @@ describe('specPages', () => { }); }); +describe('wrapTables', () => { + it('puts each table in a labelled box with a focus stop, named after the heading above', () => { + const text = '## Errors\n\nIntro.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter.\n'; + expect(wrapTables(text)).toBe( + [ + '## Errors', + '', + 'Intro.', + '', + '
', + '', + '| a | b |', + '| - | - |', + '| 1 | 2 |', + '', + '
', + '', + 'After.', + '', + ].join('\n'), + ); + }); + + it('labels a table that has no heading above it, and keeps text without a table', () => { + expect(wrapTables('| a |\n| - |\n')).toContain('aria-label="Table"'); + expect(wrapTables('Plain text.\n')).toBe('Plain text.\n'); + }); + + it('wraps every table of the spec pages that the site builds', () => { + for (const { text } of PAGES) { + expect(text.match(/^\|/gm)?.length ?? 0).toBe( + text.split('\n').filter((line) => line.startsWith('|')).length, + ); + expect(text.split('
line.startsWith('|') && !all[i - 1]?.startsWith('|')).length + + 1, + ); + } + }); +}); + describe('the spec that the site shows', () => { it('is the spec that each SDK of the site implements', () => { const php = readFileSync('php/src/Postcode.php', 'utf8'); From 89caf63cd621d97a24b0e8c84c54bf2e520b8ad1 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:42:50 +0100 Subject: [PATCH 08/27] feat: add a playground that parses postcodes in the browser Readers learn the parser fastest by typing into it. The playground runs @gatepost/core in the browser and shows the segments, the forms and the suggestion for each input. It makes no request and keeps nothing, and its strings come from a message catalogue. The tests drive it with the keyboard alone and run axe while a suggestion shows. Signed-off-by: MobileMage --- astro.config.mjs | 1 + src/components/Playground.astro | 150 ++++++++++++++++++++++++++++++++ src/content/docs/playground.mdx | 12 +++ src/content/docs/privacy.md | 4 + src/messages/en-GB.json | 42 +++++++++ src/playground/describe.ts | 138 +++++++++++++++++++++++++++++ src/playground/view.ts | 95 ++++++++++++++++++++ test/browser/playground.spec.ts | 72 +++++++++++++++ test/unit/describe.test.ts | 89 +++++++++++++++++++ vitest.config.ts | 6 ++ 10 files changed, 609 insertions(+) create mode 100644 src/components/Playground.astro create mode 100644 src/content/docs/playground.mdx create mode 100644 src/messages/en-GB.json create mode 100644 src/playground/describe.ts create mode 100644 src/playground/view.ts create mode 100644 test/browser/playground.spec.ts create mode 100644 test/unit/describe.test.ts diff --git a/astro.config.mjs b/astro.config.mjs index 1a6f299..c288fbb 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -74,6 +74,7 @@ export default defineConfig({ expressiveCode: { defaultProps: { wrap: true } }, plugins: [coreTypeDoc(typeDocOf('core')), clientTypeDoc(typeDocOf('client'))], sidebar: [ + { label: 'Start', items: ['playground'] }, { label: 'Spec', items: [{ autogenerate: { directory: 'spec' } }] }, { label: 'API reference', items: [coreTypeDocGroup, clientTypeDocGroup] }, { label: 'Project', items: ['privacy'] }, diff --git a/src/components/Playground.astro b/src/components/Playground.astro new file mode 100644 index 0000000..53e72c7 --- /dev/null +++ b/src/components/Playground.astro @@ -0,0 +1,150 @@ +--- +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The playground runs @gatepost/core in the reader's browser. The field has no form and no name, +// so the Enter key sends nothing, and the address bar never holds what the reader typed. +import { message } from '../playground/describe.ts'; +--- + +
+ +

+ {message('playground.hint')} +

+ + +

+ {message('playground.idle')} +

+ +

{message('playground.segments')}

+
    +

    {message('playground.details')}

    +
    +

    {message('accuracy.heading')}

    + +

    + {message('accuracy.hint')} +

    + +

    + +
    + + + + diff --git a/src/content/docs/playground.mdx b/src/content/docs/playground.mdx new file mode 100644 index 0000000..6fd8e67 --- /dev/null +++ b/src/content/docs/playground.mdx @@ -0,0 +1,12 @@ +--- +title: Playground +description: Try the postcode parser of @gatepost/core in your browser, with no network call. +--- + +import Playground from '../../components/Playground.astro'; + +The playground runs `@gatepost/core` in your browser. It checks the form of the text as you type, and it shows the forms and the segments of a postcode. It sends nothing to any server, and it keeps nothing. + + + +The playground checks the form only. Only NIPOST's gateway can say whether a building has a postcode. diff --git a/src/content/docs/privacy.md b/src/content/docs/privacy.md index 14b78de..3d700bf 100644 --- a/src/content/docs/privacy.md +++ b/src/content/docs/privacy.md @@ -16,6 +16,10 @@ Your browser keeps two settings of this site, and the site never sends them anyw The search box runs in your browser. It reads an index that this site built when it was published. Your search text stays in your browser. +## Playground + +The playground runs in your browser. It sends what you type to no server, and it keeps none of it. The address bar never holds what you type. + ## Hosting GitHub hosts this site on GitHub Pages. [GitHub logs the IP address of each visitor](https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages#data-collection) for security. The Gatepost team does not see these logs. diff --git a/src/messages/en-GB.json b/src/messages/en-GB.json new file mode 100644 index 0000000..9e91d54 --- /dev/null +++ b/src/messages/en-GB.json @@ -0,0 +1,42 @@ +{ + "playground.label": "Postcode", + "playground.hint": "Type or paste a postcode, such as ek 01 a03 fk 01. Nothing you type leaves this page.", + "playground.partial": "Accept a partial postcode", + "playground.idle": "The playground checks the form of the text as you type.", + "playground.segments": "Segments", + "playground.details": "Forms of the postcode", + "playground.useSuggestion": "Use the suggestion", + "segment.state": "State", + "segment.lga": "LGA", + "segment.district": "District", + "segment.area": "Area", + "segment.unit": "Unit", + "segment.problem": "Problem", + "form.canonical": "Canonical", + "form.display": "Display", + "form.compact": "Compact", + "form.redacted": "For logs", + "form.stateName": "State name", + "form.parent": "One segment up", + "result.unit": "The text has the form of a postcode. Only NIPOST's gateway can say whether a building has it.", + "result.partial": "The text has the form of a partial postcode.", + "result.suggestion": "Did you mean this postcode?", + "error.empty": "Type a postcode.", + "error.legacy_code": "This is an old 6-digit postcode. Ask for the new postcode of 11 letters and digits.", + "error.bad_character": "A postcode holds only letters and digits.", + "error.bad_length": "A postcode has 11 letters and digits.", + "error.bad_length.partial": "A partial postcode has 2, 4, 7 or 9 letters and digits.", + "error.unknown_state": "The first two letters do not name a state.", + "error.bad_segment.lga": "The LGA is two digits from 01 to 99.", + "error.bad_segment.district": "The district is three letters or digits.", + "error.bad_segment.area": "The area is two letters.", + "error.bad_segment.unit": "The unit is two digits from 01 to 99.", + "accuracy.label": "GPS accuracy in metres", + "accuracy.hint": "A phone reports the accuracy of each GPS fix.", + "accuracy.lga": "A fix this rough can show the postcode up to the LGA.", + "accuracy.district": "A fix of this accuracy can show the postcode up to the district.", + "accuracy.area": "A fix of this accuracy can show the postcode up to the area.", + "accuracy.unit": "A fix of this accuracy can show the whole postcode, with the unit.", + "playground.noScript": "The playground needs JavaScript. The guides show the same results.", + "accuracy.heading": "Match a GPS fix" +} diff --git a/src/playground/describe.ts b/src/playground/describe.ts new file mode 100644 index 0000000..6528523 --- /dev/null +++ b/src/playground/describe.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Turns the text of the playground into what the page shows. It calls only @gatepost/core, so +// it needs no network, and each string comes from the message catalogue (UI-2). +import { + parent, + parse, + precisionForAccuracy, + redact, + stateName, + type ParseError, + type Postcode, + type Precision, +} from '@gatepost/core'; +import messages from '../messages/en-GB.json'; + +/** The name of a string in the message catalogue. */ +export type MessageKey = keyof typeof messages; + +/** One segment box of the playground. */ +export interface SegmentView { + readonly name: Precision; + readonly label: string; + readonly text: string; + readonly problem: boolean; +} + +/** What the playground shows for the text in its field. */ +export interface PostcodeView { + readonly status: 'idle' | 'valid' | 'invalid'; + readonly message: string; + readonly segments: readonly SegmentView[]; + readonly forms: readonly { readonly label: string; readonly value: string }[]; + readonly suggestion: string | null; +} + +const SEGMENT_NAMES: readonly Precision[] = ['state', 'lga', 'district', 'area', 'unit']; + +/** + * Reads a string from the message catalogue. + * + * @param key - The name of the string. + * @returns The string. + */ +export function message(key: MessageKey): string { + return messages[key]; +} + +function isMessageKey(key: string): key is MessageKey { + return Object.hasOwn(messages, key); +} + +/** + * Names the message for a parse error. A segment error has one message for each segment, so + * no sentence is built from parts. + * + * @param error - The error that `parse` returned. + * @param allowPartial - Whether the playground accepts a partial postcode. + * @returns The name of the message. + */ +export function errorMessageKey(error: ParseError, allowPartial: boolean): MessageKey { + const candidates = [ + allowPartial ? `error.${error.code}.partial` : '', + `error.${error.code}.${error.segment ?? ''}`, + `error.${error.code}`, + ]; + const key = candidates.find(isMessageKey); + if (key === undefined) throw new Error(`The catalogue has no message for ${error.code}.`); + return key; +} + +function segmentViews(postcode: Postcode | null, problem: Precision | null): SegmentView[] { + return SEGMENT_NAMES.map((name) => ({ + name, + label: message(`segment.${name}`), + text: postcode?.segments[name] ?? '', + problem: name === problem, + })); +} + +function formsOf(postcode: Postcode): PostcodeView['forms'] { + const forms = [ + { label: message('form.canonical'), value: postcode.canonical }, + { label: message('form.display'), value: postcode.display }, + { label: message('form.compact'), value: postcode.compact }, + { label: message('form.stateName'), value: stateName(postcode.segments.state) ?? '' }, + ]; + const up = parent(postcode); + if (up !== null) forms.push({ label: message('form.parent'), value: up.canonical }); + if (postcode.precision === 'unit') { + forms.push({ label: message('form.redacted'), value: redact(postcode) }); + } + return forms; +} + +/** + * Describes the text of the playground field. + * + * @param input - The text that the reader typed. + * @param allowPartial - Whether to accept a partial postcode. + * @returns What the page shows. + */ +export function describePostcode(input: string, allowPartial: boolean): PostcodeView { + const result = parse(input, { allowPartial }); + if (result.ok) { + const key = result.value.precision === 'unit' ? 'result.unit' : 'result.partial'; + return { + status: 'valid', + message: message(key), + segments: segmentViews(result.value, null), + forms: formsOf(result.value), + suggestion: null, + }; + } + const { error } = result; + return { + status: error.code === 'empty' ? 'idle' : 'invalid', + message: message( + error.code === 'empty' ? 'playground.idle' : errorMessageKey(error, allowPartial), + ), + segments: segmentViews(null, error.segment), + forms: [], + suggestion: error.suggestion, + }; +} + +/** + * Describes what a GPS fix of a given accuracy can show. + * + * @param text - The accuracy in metres, as the reader typed it. Empty text gives null. + * @returns The message, or null when the field is empty. + */ +export function describeAccuracy(text: string): string | null { + if (text.trim() === '') return null; + const key = `accuracy.${precisionForAccuracy(Number(text))}`; + if (!isMessageKey(key)) throw new Error(`The catalogue has no message ${key}.`); + return message(key); +} diff --git a/src/playground/view.ts b/src/playground/view.ts new file mode 100644 index 0000000..22dcceb --- /dev/null +++ b/src/playground/view.ts @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Connects the playground's markup to describePostcode. It writes text only, never HTML, and it +// keeps nothing: no request, no storage and no change of the address bar. +import { describeAccuracy, describePostcode, message, type PostcodeView } from './describe.ts'; + +// A screen reader hears the status after the typing pauses, not at each keystroke. +const STATUS_DELAY_MS = 600; + +function part(root: HTMLElement, name: string): HTMLElement { + const element = root.querySelector(`[data-part="${name}"]`); + if (element === null) throw new Error(`The playground has no part named ${name}.`); + return element; +} + +function inputPart(root: HTMLElement, name: string): HTMLInputElement { + const element = part(root, name); + if (!(element instanceof HTMLInputElement)) throw new Error(`${name} is not an input.`); + return element; +} + +function renderSegments(list: HTMLElement, view: PostcodeView): void { + list.replaceChildren( + ...view.segments.map((segment) => { + const item = document.createElement('li'); + item.dataset['problem'] = String(segment.problem); + const label = document.createElement('span'); + label.textContent = segment.label; + const text = document.createElement('code'); + text.textContent = segment.text; + item.append(label, text); + if (segment.problem) { + const problem = document.createElement('strong'); + problem.textContent = message('segment.problem'); + item.append(problem); + } + return item; + }), + ); +} + +function renderForms(list: HTMLElement, view: PostcodeView): void { + list.replaceChildren( + ...view.forms.flatMap(({ label, value }) => { + const term = document.createElement('dt'); + term.textContent = label; + const detail = document.createElement('dd'); + const code = document.createElement('code'); + code.textContent = value; + detail.append(code); + return [term, detail]; + }), + ); +} + +/** + * Starts the playground in its markup. + * + * @param root - The element that holds the parts of the playground. + */ +export function startPlayground(root: HTMLElement): void { + const input = inputPart(root, 'postcode'); + const partial = inputPart(root, 'partial'); + const status = part(root, 'status'); + const suggestion = part(root, 'suggestion'); + const suggested = part(root, 'suggested'); + const accuracy = inputPart(root, 'accuracy'); + const accuracyStatus = part(root, 'accuracy-status'); + let timer: ReturnType | undefined; + + const render = (): void => { + const view = describePostcode(input.value, partial.checked); + root.dataset['status'] = view.status; + renderSegments(part(root, 'segments'), view); + renderForms(part(root, 'forms'), view); + suggestion.hidden = view.suggestion === null; + suggested.textContent = view.suggestion ?? ''; + clearTimeout(timer); + timer = setTimeout(() => { + status.textContent = view.message; + }, STATUS_DELAY_MS); + }; + + input.addEventListener('input', render); + partial.addEventListener('change', render); + part(root, 'use-suggestion').addEventListener('click', () => { + input.value = suggested.textContent; + render(); + input.focus(); + }); + accuracy.addEventListener('input', () => { + accuracyStatus.textContent = describeAccuracy(accuracy.value) ?? ''; + }); + render(); +} diff --git a/test/browser/playground.spec.ts b/test/browser/playground.spec.ts new file mode 100644 index 0000000..ef8ee10 --- /dev/null +++ b/test/browser/playground.spec.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/docs/playground/'); +}); + +test('shows the forms of a typed postcode', async ({ page }) => { + await page.getByLabel('Postcode', { exact: true }).fill('ek 01 a03 fk 01'); + const forms = page.getByRole('definition'); + await expect(forms).toHaveText([ + 'EK-01-A03-FK-01', + 'EK 01 A03 FK 01', + 'EK01A03FK01', + 'Ekiti', + 'EK-01-A03-FK', + 'EK-01-A03-FK-**', + ]); + await expect(page.getByRole('status').first()).toHaveText( + "The text has the form of a postcode. Only NIPOST's gateway can say whether a building has it.", + ); +}); + +test('works with the keyboard alone, from the field to the suggestion', async ({ page }) => { + const field = page.getByLabel('Postcode', { exact: true }); + await field.focus(); + await page.keyboard.type('EK-O1-A03-FK-01'); + await expect(page.getByRole('listitem').filter({ hasText: 'Problem' })).toHaveText(/LGA/); + await page.keyboard.press('Tab'); + await expect(page.getByRole('checkbox', { name: 'Accept a partial postcode' })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Use the suggestion' })).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(field).toHaveValue('EK-01-A03-FK-01'); + await expect(field).toBeFocused(); +}); + +// The site may load its own scripts later, such as the search code, but no request may leave the +// site or carry a query. +test('sends nothing and keeps the typed text out of the address bar', async ({ page, baseURL }) => { + const requests: URL[] = []; + page.on('request', (request) => requests.push(new URL(request.url()))); + const field = page.getByLabel('Postcode', { exact: true }); + await field.fill('EK-01-A03-FK-01'); + await field.press('Enter'); + await page.getByLabel('GPS accuracy in metres').fill('6'); + await expect(page.getByRole('status').last()).toHaveText( + 'A fix of this accuracy can show the whole postcode, with the unit.', + ); + expect(requests.filter((url) => url.origin !== baseURL || url.search !== '')).toEqual([]); + expect(new URL(page.url()).search).toBe(''); + const stored = await page.evaluate(() => [ + ...Object.keys(localStorage), + ...Object.keys(sessionStorage), + document.cookie, + ]); + // Only the settings of the site itself, which the privacy page names, may be there. + expect( + stored.filter((key) => !['starlight-theme', 'sl-sidebar-state', ''].includes(key)), + ).toEqual([]); +}); + +test('has no WCAG 2.2 AA violation while it shows a suggestion', async ({ page }) => { + await page.getByLabel('Postcode', { exact: true }).fill('EK-O1-A03-FK-01'); + await expect(page.getByRole('button', { name: 'Use the suggestion' })).toBeVisible(); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(results.violations.map(({ id }) => id)).toEqual([]); +}); diff --git a/test/unit/describe.test.ts b/test/unit/describe.test.ts new file mode 100644 index 0000000..b85a57b --- /dev/null +++ b/test/unit/describe.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { parse } from '@gatepost/core'; +import { describe, expect, it } from 'vitest'; +import { + describeAccuracy, + describePostcode, + errorMessageKey, +} from '../../src/playground/describe.ts'; + +interface ParseVector { + readonly input: string; + readonly options: { readonly allowPartial?: boolean }; +} + +// Each parse vector of the spec, so that each error that parse can give has its message. +const VECTORS = ['parse', 'parse-segments', 'parse-states'].flatMap( + (name) => + (JSON.parse(readFileSync(`spec/vectors/${name}.json`, 'utf8')) as { cases: ParseVector[] }) + .cases, +); + +describe('describePostcode', () => { + it('shows each form, the state name, the parent and the form for logs of a postcode', () => { + const view = describePostcode('ek 01 a03 fk 01', false); + expect(view.status).toBe('valid'); + expect(view.forms).toEqual([ + { label: 'Canonical', value: 'EK-01-A03-FK-01' }, + { label: 'Display', value: 'EK 01 A03 FK 01' }, + { label: 'Compact', value: 'EK01A03FK01' }, + { label: 'State name', value: 'Ekiti' }, + { label: 'One segment up', value: 'EK-01-A03-FK' }, + { label: 'For logs', value: 'EK-01-A03-FK-**' }, + ]); + expect(view.segments.map(({ text }) => text)).toEqual(['EK', '01', 'A03', 'FK', '01']); + }); + + it('shows a partial postcode with its empty segments, and no form for logs', () => { + const view = describePostcode('EK 01 A03', true); + expect(view.message).toBe('The text has the form of a partial postcode.'); + expect(view.segments.map(({ text }) => text)).toEqual(['EK', '01', 'A03', '', '']); + expect(view.forms.map(({ label }) => label)).not.toContain('For logs'); + }); + + it('marks the failing segment and offers the suggestion of parse', () => { + const view = describePostcode('EK-O1-A03-FK-01', false); + expect(view.status).toBe('invalid'); + expect(view.message).toBe('The LGA is two digits from 01 to 99.'); + expect(view.segments.filter(({ problem }) => problem).map(({ name }) => name)).toEqual(['lga']); + expect(view.suggestion).toBe('EK-01-A03-FK-01'); + }); + + it('asks for the new postcode when the text is an old one', () => { + expect(describePostcode('900108', false).message).toBe( + 'This is an old 6-digit postcode. Ask for the new postcode of 11 letters and digits.', + ); + }); + + it('names the lengths of a partial postcode when it accepts one', () => { + expect(describePostcode('EK0', true).message).toBe( + 'A partial postcode has 2, 4, 7 or 9 letters and digits.', + ); + expect(describePostcode('EK0', false).message).toBe('A postcode has 11 letters and digits.'); + }); + + it('stays idle with no text', () => { + expect(describePostcode(' ', false)).toMatchObject({ status: 'idle', forms: [] }); + }); + + it.each(VECTORS)('has a message for the parse vector $input', ({ input, options }) => { + const result = parse(input, options); + if (!result.ok) { + expect(() => errorMessageKey(result.error, options.allowPartial ?? false)).not.toThrow(); + } + }); +}); + +describe('describeAccuracy', () => { + it('names the segments that a fix of each accuracy can show', () => { + expect(['6', '15', '35', '80', ''].map(describeAccuracy)).toEqual([ + 'A fix of this accuracy can show the whole postcode, with the unit.', + 'A fix of this accuracy can show the postcode up to the area.', + 'A fix of this accuracy can show the postcode up to the district.', + 'A fix this rough can show the postcode up to the LGA.', + null, + ]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index fdb72d6..7fc2ee3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,5 +9,11 @@ export default defineConfig({ { test: { name: 'unit', include: ['test/unit/**/*.test.ts'] } }, { test: { name: 'site', include: ['test/site/**/*.test.ts'] } }, ], + // The unit tests measure the logic of the playground. The browser tests cover its DOM module, + // view.ts. + coverage: { + include: ['src/playground/describe.ts'], + thresholds: { branches: 80 }, + }, }, }); From e7d169cfcf919fedcb8275e0cd296d84a4f1ad5d Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:45:22 +0100 Subject: [PATCH 09/27] feat: show the answers of the client in the playground The client needs a key and a network, so the playground cannot run it. The build now runs the real client against the mock gateway and shows its answers, an error included, as static code on the playground page. Each answer uses a synthetic postcode, and the page makes no call. Signed-off-by: MobileMage --- astro.config.mjs | 4 +- package.json | 1 + pnpm-lock.yaml | 3 + scripts/client-samples.ts | 94 ++++++++++++++++++++++++++++++ src/components/ClientSamples.astro | 15 +++++ src/content/docs/playground.mdx | 7 +++ test/site/playground.test.ts | 13 +++++ test/unit/client-samples.test.ts | 23 ++++++++ 8 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 scripts/client-samples.ts create mode 100644 src/components/ClientSamples.astro create mode 100644 test/site/playground.test.ts create mode 100644 test/unit/client-samples.test.ts diff --git a/astro.config.mjs b/astro.config.mjs index c288fbb..fb6f25d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,6 +3,7 @@ import starlight from '@astrojs/starlight'; import { defineConfig } from 'astro/config'; import { createStarlightTypeDocPlugin } from 'starlight-typedoc'; +import { writeClientSamples } from './scripts/client-samples.ts'; import { writeSpecPages } from './scripts/spec-pages.ts'; // GitHub Pages serves the org's project site under the repo name. A custom domain changes the @@ -37,8 +38,9 @@ export default defineConfig({ { name: 'gatepost-generated-pages', hooks: { - 'astro:config:setup': () => { + 'astro:config:setup': async () => { writeSpecPages(import.meta.dirname); + await writeClientSamples(import.meta.dirname); }, }, }, diff --git a/package.json b/package.json index 565970a..cb50f9f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@commitlint/config-conventional": "^21.2.3", "@eslint-community/eslint-plugin-eslint-comments": "^4.8.1", "@eslint/js": "^10.0.1", + "@gatepost/mock-server": "link:js/packages/mock-server", "@playwright/test": "^1.63.0", "@types/node": "^24.19.1", "eslint": "^10.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2cfbb5..bf4a54b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.11.0(jiti@2.6.1)) + '@gatepost/mock-server': + specifier: link:js/packages/mock-server + version: link:js/packages/mock-server '@playwright/test': specifier: ^1.63.0 version: 1.63.0 diff --git a/scripts/client-samples.ts b/scripts/client-samples.ts new file mode 100644 index 0000000..ab37711 --- /dev/null +++ b/scripts/client-samples.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Records what @gatepost/client returns for a few calls, so that the playground can show real +// results with no network. The build runs the client against Gatepost's mock gateway, which +// answers with the synthetic fixtures of the spec. +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PostcodeClient, PostcodeError } from '@gatepost/client'; +import { startMockServer } from '@gatepost/mock-server'; + +/** One call of the client, as the page shows it, and what the client returned. */ +export interface ClientSample { + readonly call: string; + readonly result: unknown; +} + +interface SampleCall { + readonly key: string; + readonly call: string; + readonly run: (client: PostcodeClient) => Promise; +} + +// The keys come from spec/fixtures/keys.json. Each one makes the mock gateway act as a real +// key with that grant would. +const CALLS: readonly SampleCall[] = [ + { + key: 'nipost_test_mock_l3', + call: "await client.lookup('FC-01-Z99-ZZ-01')", + run: (client) => client.lookup('FC-01-Z99-ZZ-01'), + }, + { + key: 'nipost_test_mock_l3', + call: "await client.lookup('FC-01-Z99-ZZ-01', { level: 2 })", + run: (client) => client.lookup('FC-01-Z99-ZZ-01', { level: 2 }), + }, + { + key: 'nipost_test_mock_l3', + call: "await client.lookup('FC-01-Z99-ZZ-02')", + run: (client) => client.lookup('FC-01-Z99-ZZ-02'), + }, + { + key: 'nipost_test_mock_l3', + call: 'await client.reverse(9, 7)', + run: (client) => client.reverse(9, 7), + }, + { + key: 'nipost_test_mock_l3', + call: "await client.autocomplete('fc 01 z')", + run: (client) => client.autocomplete('fc 01 z'), + }, + { + key: 'nipost_test_mock_no_credits', + call: "await client.lookup('FC-01-Z99-ZZ-01', { level: 2 }) // a key with no credits", + run: (client) => client.lookup('FC-01-Z99-ZZ-01', { level: 2 }), + }, +]; + +// An error shows the fields that a caller reads, as the client guide lists them. +function outcome(error: unknown): unknown { + if (!(error instanceof PostcodeError)) throw error; + const { name, code, status, apiCode, retryAfterMs } = error; + return { name, code, status, apiCode, retryAfterMs }; +} + +/** + * Makes each sample call against a mock gateway that runs for the length of the calls. + * + * @returns Each call with its result, in order. + */ +export async function recordClientSamples(): Promise { + const mock = await startMockServer({ port: 0 }); + try { + const samples: ClientSample[] = []; + for (const { key, call, run } of CALLS) { + const client = new PostcodeClient({ apiKey: key, baseUrl: mock.url }); + samples.push({ call, result: await run(client).catch(outcome) }); + } + return samples; + } finally { + await mock.close(); + } +} + +/** + * Writes the samples to `src/generated/client-samples.json`, which the playground page reads. + * + * @param root - The root folder of the docs repo. + */ +export async function writeClientSamples(root: string): Promise { + const folder = join(root, 'src/generated'); + mkdirSync(folder, { recursive: true }); + const samples = await recordClientSamples(); + writeFileSync(join(folder, 'client-samples.json'), `${JSON.stringify(samples, null, 2)}\n`); +} diff --git a/src/components/ClientSamples.astro b/src/components/ClientSamples.astro new file mode 100644 index 0000000..5cb3d27 --- /dev/null +++ b/src/components/ClientSamples.astro @@ -0,0 +1,15 @@ +--- +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The answers of the client that the build recorded against the mock gateway. The page needs no +// script and makes no call to show them. +import { Code } from '@astrojs/starlight/components'; +import samples from '../generated/client-samples.json'; +--- + +{samples.map(({ call, result }) => ( + + + + +))} diff --git a/src/content/docs/playground.mdx b/src/content/docs/playground.mdx index 6fd8e67..22687d8 100644 --- a/src/content/docs/playground.mdx +++ b/src/content/docs/playground.mdx @@ -3,6 +3,7 @@ title: Playground description: Try the postcode parser of @gatepost/core in your browser, with no network call. --- +import ClientSamples from '../../components/ClientSamples.astro'; import Playground from '../../components/Playground.astro'; The playground runs `@gatepost/core` in your browser. It checks the form of the text as you type, and it shows the forms and the segments of a postcode. It sends nothing to any server, and it keeps nothing. @@ -10,3 +11,9 @@ The playground runs `@gatepost/core` in your browser. It checks the form of the The playground checks the form only. Only NIPOST's gateway can say whether a building has a postcode. + +## What the client returns + +`@gatepost/client` needs a key and a network, so the playground does not run it. These are the answers that the client gave to Gatepost's mock gateway when this site was built. The answers use synthetic postcodes with the district `Z99`. + + diff --git a/test/site/playground.test.ts b/test/site/playground.test.ts new file mode 100644 index 0000000..f3ebda1 --- /dev/null +++ b/test/site/playground.test.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { builtFiles } from './built-site.ts'; + +const PLAYGROUND = builtFiles('.html').find(({ path }) => path === 'playground/index.html'); + +describe('the playground page', () => { + it('shows the answers that the client gave to the mock gateway at build time', () => { + expect(PLAYGROUND?.text).toContain('SYNTHETIC LGA'); + expect(PLAYGROUND?.text).toContain('insufficient_credits'); + }); +}); diff --git a/test/unit/client-samples.test.ts b/test/unit/client-samples.test.ts new file mode 100644 index 0000000..bd87b1e --- /dev/null +++ b/test/unit/client-samples.test.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { recordClientSamples } from '../../scripts/client-samples.ts'; + +describe('recordClientSamples', () => { + it('records the answers of the client to the mock gateway, errors included', async () => { + const samples = await recordClientSamples(); + expect(samples.map(({ result }) => result)).toMatchObject([ + { valid: true, levelReceived: 1 }, + { valid: true, levelReceived: 2, administrativeAddress: { lgaName: 'SYNTHETIC LGA' } }, + { valid: false, status: 'not_found' }, + { found: true, unit: { postcode: { canonical: 'FC-01-Z99-ZZ-01' } } }, + { segment: 'district', suggestions: [{ code: 'Z99' }] }, + { name: 'PostcodeError', code: 'insufficient_credits', status: 402 }, + ]); + }); + + it('shows each call as the page prints it', async () => { + const [first] = await recordClientSamples(); + expect(first?.call).toBe("await client.lookup('FC-01-Z99-ZZ-01')"); + }); +}); From 43b1f8c7ee5b9220f4161f616e12b8bd7086de89 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:47:46 +0100 Subject: [PATCH 10/27] fix: enforce the playground coverage floor and tighten its checks The branch floor never ran, because the test script had no coverage flag and the coverage provider was not installed. The script now measures coverage, and a run with the playground tests skipped fails on the floor. The accuracy status now waits for a pause in typing, like the postcode status. A negative or invalid accuracy gets its own message. The unused district message is gone, so a new district rule fails loudly. New tests pin the message of each error, the delay of the status and the layout of the page at 320 pixels. Signed-off-by: MobileMage --- package.json | 3 +- pnpm-lock.yaml | 69 ++++++++++++++++++++++++++++++++- src/messages/en-GB.json | 2 +- src/playground/describe.ts | 7 +++- src/playground/view.ts | 7 +++- test/browser/playground.spec.ts | 24 ++++++++++++ test/unit/describe.test.ts | 20 ++++++++++ 7 files changed, 125 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index cb50f9f..39b8dd4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "format:check": "prettier --check .", "lint": "eslint . --max-warnings 0", "typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check", - "test": "vitest run --project unit", + "test": "vitest run --project unit --coverage", "test:site": "vitest run --project site", "tells": "python3 spec/scripts/check-tells", "prose": "node scripts/check-prose.ts", @@ -58,6 +58,7 @@ "prettier-plugin-astro": "^1.1.0", "typescript": "6.0.3", "typescript-eslint": "^8.71.0", + "@vitest/coverage-v8": "^5.0.3", "vitest": "^5.0.3", "yaml": "^2.9.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf4a54b..0b3a05f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: '@types/node': specifier: ^24.19.1 version: 24.19.1 + '@vitest/coverage-v8': + specifier: ^5.0.3 + version: 5.0.3(vitest@5.0.3) eslint: specifier: ^10.11.0 version: 10.11.0(jiti@2.6.1) @@ -238,7 +241,7 @@ importers: version: 8.71.0(eslint@10.11.0(jiti@2.6.1))(typescript@6.0.3) vitest: specifier: ^5.0.3 - version: 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) yaml: specifier: ^2.9.1 version: 2.9.1 @@ -475,6 +478,10 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@bruits/satteri-darwin-arm64@0.10.5': resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} cpu: [arm64] @@ -1412,6 +1419,23 @@ packages: '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vitest/coverage-v8@5.0.3': + resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==} + peerDependencies: + '@vitest/browser': 5.0.3 + vitest: 5.0.3 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + '@vitest/mocker@5.0.3': resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: @@ -1534,6 +1558,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + astro-expressive-code@0.44.2: resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} peerDependencies: @@ -2086,6 +2113,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2715,6 +2745,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -3416,6 +3450,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@bruits/satteri-darwin-arm64@0.10.5': optional: true @@ -4244,6 +4280,24 @@ snapshots: '@ungap/structured-clone@1.4.0': {} + '@vitest/coverage-v8@5.0.3(vitest@5.0.3)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -4364,6 +4418,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astro-expressive-code@0.44.2(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1)): dependencies: astro: 7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1) @@ -5031,6 +5091,8 @@ snapshots: jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -5786,6 +5848,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@3.2.0: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -5937,7 +6001,7 @@ snapshots: optionalDependencies: vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) - vitest@5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): + vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) @@ -5955,6 +6019,7 @@ snapshots: why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 24.19.1 + '@vitest/coverage-v8': 5.0.3(vitest@5.0.3) transitivePeerDependencies: - msw diff --git a/src/messages/en-GB.json b/src/messages/en-GB.json index 9e91d54..e95bb7f 100644 --- a/src/messages/en-GB.json +++ b/src/messages/en-GB.json @@ -28,11 +28,11 @@ "error.bad_length.partial": "A partial postcode has 2, 4, 7 or 9 letters and digits.", "error.unknown_state": "The first two letters do not name a state.", "error.bad_segment.lga": "The LGA is two digits from 01 to 99.", - "error.bad_segment.district": "The district is three letters or digits.", "error.bad_segment.area": "The area is two letters.", "error.bad_segment.unit": "The unit is two digits from 01 to 99.", "accuracy.label": "GPS accuracy in metres", "accuracy.hint": "A phone reports the accuracy of each GPS fix.", + "accuracy.invalid": "An accuracy is a number of metres, zero or more.", "accuracy.lga": "A fix this rough can show the postcode up to the LGA.", "accuracy.district": "A fix of this accuracy can show the postcode up to the district.", "accuracy.area": "A fix of this accuracy can show the postcode up to the area.", diff --git a/src/playground/describe.ts b/src/playground/describe.ts index 6528523..1100f5b 100644 --- a/src/playground/describe.ts +++ b/src/playground/describe.ts @@ -128,11 +128,14 @@ export function describePostcode(input: string, allowPartial: boolean): Postcode * Describes what a GPS fix of a given accuracy can show. * * @param text - The accuracy in metres, as the reader typed it. Empty text gives null. - * @returns The message, or null when the field is empty. + * @returns The message, or null when the field is empty. A negative or invalid number gives the + * message for an invalid accuracy. */ export function describeAccuracy(text: string): string | null { if (text.trim() === '') return null; - const key = `accuracy.${precisionForAccuracy(Number(text))}`; + const metres = Number(text); + if (!Number.isFinite(metres) || metres < 0) return message('accuracy.invalid'); + const key = `accuracy.${precisionForAccuracy(metres)}`; if (!isMessageKey(key)) throw new Error(`The catalogue has no message ${key}.`); return message(key); } diff --git a/src/playground/view.ts b/src/playground/view.ts index 22dcceb..ade9c50 100644 --- a/src/playground/view.ts +++ b/src/playground/view.ts @@ -67,6 +67,7 @@ export function startPlayground(root: HTMLElement): void { const accuracy = inputPart(root, 'accuracy'); const accuracyStatus = part(root, 'accuracy-status'); let timer: ReturnType | undefined; + let accuracyTimer: ReturnType | undefined; const render = (): void => { const view = describePostcode(input.value, partial.checked); @@ -89,7 +90,11 @@ export function startPlayground(root: HTMLElement): void { input.focus(); }); accuracy.addEventListener('input', () => { - accuracyStatus.textContent = describeAccuracy(accuracy.value) ?? ''; + const text = describeAccuracy(accuracy.value) ?? ''; + clearTimeout(accuracyTimer); + accuracyTimer = setTimeout(() => { + accuracyStatus.textContent = text; + }, STATUS_DELAY_MS); }); render(); } diff --git a/test/browser/playground.spec.ts b/test/browser/playground.spec.ts index ef8ee10..5482ab7 100644 --- a/test/browser/playground.spec.ts +++ b/test/browser/playground.spec.ts @@ -70,3 +70,27 @@ test('has no WCAG 2.2 AA violation while it shows a suggestion', async ({ page } .analyze(); expect(results.violations.map(({ id }) => id)).toEqual([]); }); + +test('announces the result after the typing pauses, not at each keystroke', async ({ page }) => { + const status = page.getByRole('status').first(); + const idle = await status.textContent(); + await page.getByLabel('Postcode', { exact: true }).fill('EK-01-A03-FK-01'); + await expect(status).toHaveText(idle ?? ''); + await expect(status).toHaveText(/form of a postcode/); +}); + +test('fits a screen 320 pixels wide while it shows a suggestion and a long value', async ({ + page, +}) => { + await page.setViewportSize({ width: 320, height: 640 }); + await page.getByLabel('Postcode', { exact: true }).fill('EK-O1-A03-FK-01'); + await expect(page.getByRole('button', { name: 'Use the suggestion' })).toBeVisible(); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(results.violations.map(({ id }) => id)).toEqual([]); + await page.getByLabel('Postcode', { exact: true }).fill('ek 01 a03 fk 01'); + await expect(page.getByRole('definition').first()).toBeVisible(); + const width = await page.evaluate(() => document.documentElement.scrollWidth); + expect(width).toBeLessThanOrEqual(320); +}); diff --git a/test/unit/describe.test.ts b/test/unit/describe.test.ts index b85a57b..6447e5f 100644 --- a/test/unit/describe.test.ts +++ b/test/unit/describe.test.ts @@ -68,6 +68,21 @@ describe('describePostcode', () => { expect(describePostcode(' ', false)).toMatchObject({ status: 'idle', forms: [] }); }); + it.each([ + ['900108', false, 'error.legacy_code'], + ['EK-01-A03-FK-0!', false, 'error.bad_character'], + ['EK0', false, 'error.bad_length'], + ['EK0', true, 'error.bad_length.partial'], + ['ZZ-01-A03-FK-01', false, 'error.unknown_state'], + ['EK-00-A03-FK-01', false, 'error.bad_segment.lga'], + ['EK-01-A03-1K-01', false, 'error.bad_segment.area'], + ['EK-01-A03-FK-00', false, 'error.bad_segment.unit'], + ] as const)('picks the message of the error for %s', (input, allowPartial, key) => { + const result = parse(input, { allowPartial }); + if (result.ok) throw new Error(`${input} parses.`); + expect(errorMessageKey(result.error, allowPartial)).toBe(key); + }); + it.each(VECTORS)('has a message for the parse vector $input', ({ input, options }) => { const result = parse(input, options); if (!result.ok) { @@ -86,4 +101,9 @@ describe('describeAccuracy', () => { null, ]); }); + + it('says that a negative or invalid number is not an accuracy', () => { + const invalid = 'An accuracy is a number of metres, zero or more.'; + expect(['-5', 'abc', 'Infinity'].map(describeAccuracy)).toEqual([invalid, invalid, invalid]); + }); }); From 77c55f579a0fbf95cc4a26d6afe3ea2e71379f37 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:49:57 +0100 Subject: [PATCH 11/27] fix: fail the build when a client sample gives an unexpected outcome The sample recorder turned every client error into a recorded answer, so a broken mock gateway could pass the build and appear on the page as normal output. Each sample now names its outcome, success or one exact error code, and the build fails with both outcomes when they differ. The recorder also refuses a gateway that is not on a loopback host, and the tests check that the gateway stops after a failure. Signed-off-by: MobileMage --- scripts/client-samples.ts | 56 ++++++++++++++++++++++---- test/site/playground.test.ts | 4 +- test/unit/client-samples.test.ts | 69 +++++++++++++++++++++++++++++--- 3 files changed, 114 insertions(+), 15 deletions(-) diff --git a/scripts/client-samples.ts b/scripts/client-samples.ts index ab37711..b1a9040 100644 --- a/scripts/client-samples.ts +++ b/scripts/client-samples.ts @@ -14,9 +14,14 @@ export interface ClientSample { readonly result: unknown; } -interface SampleCall { +/** The outcome that a sample expects: a result, or the exact code of one error. */ +type Expected = 'success' | PostcodeError['code']; + +/** One call to make, with the outcome that the build requires of it. */ +export interface SampleCall { readonly key: string; readonly call: string; + readonly expected: Expected; readonly run: (client: PostcodeClient) => Promise; } @@ -26,54 +31,89 @@ const CALLS: readonly SampleCall[] = [ { key: 'nipost_test_mock_l3', call: "await client.lookup('FC-01-Z99-ZZ-01')", + expected: 'success', run: (client) => client.lookup('FC-01-Z99-ZZ-01'), }, { key: 'nipost_test_mock_l3', call: "await client.lookup('FC-01-Z99-ZZ-01', { level: 2 })", + expected: 'success', run: (client) => client.lookup('FC-01-Z99-ZZ-01', { level: 2 }), }, { key: 'nipost_test_mock_l3', call: "await client.lookup('FC-01-Z99-ZZ-02')", + expected: 'success', run: (client) => client.lookup('FC-01-Z99-ZZ-02'), }, { key: 'nipost_test_mock_l3', call: 'await client.reverse(9, 7)', + expected: 'success', run: (client) => client.reverse(9, 7), }, { key: 'nipost_test_mock_l3', call: "await client.autocomplete('fc 01 z')", + expected: 'success', run: (client) => client.autocomplete('fc 01 z'), }, { key: 'nipost_test_mock_no_credits', call: "await client.lookup('FC-01-Z99-ZZ-01', { level: 2 }) // a key with no credits", + expected: 'insufficient_credits', run: (client) => client.lookup('FC-01-Z99-ZZ-01', { level: 2 }), }, ]; // An error shows the fields that a caller reads, as the client guide lists them. -function outcome(error: unknown): unknown { - if (!(error instanceof PostcodeError)) throw error; +function describeError(error: PostcodeError): unknown { const { name, code, status, apiCode, retryAfterMs } = error; return { name, code, status, apiCode, retryAfterMs }; } +function mismatch(call: string, expected: Expected, got: string): Error { + return new Error(`Sample "${call}" expected ${expected} but got ${got}.`); +} + +// The no-NIPOST rule rests on every client calling the mock gateway on this machine. +function assertLoopback(url: string): void { + const { hostname } = new URL(url); + if (!['127.0.0.1', 'localhost', '[::1]'].includes(hostname)) { + throw new Error(`The mock gateway listens on ${hostname}, which is not a loopback host.`); + } +} + +async function record(client: PostcodeClient, sample: SampleCall): Promise { + const { call, expected, run } = sample; + try { + const result = await run(client); + if (expected !== 'success') throw mismatch(call, expected, 'success'); + return { call, result }; + } catch (error) { + if (!(error instanceof PostcodeError)) throw error; + if (error.code !== expected) throw mismatch(call, expected, error.code); + return { call, result: describeError(error) }; + } +} + /** - * Makes each sample call against a mock gateway that runs for the length of the calls. + * Makes each sample call against a mock gateway that runs for the length of the calls. The build + * fails when a call ends in any outcome other than the one that the sample expects. * + * @param calls - The calls to make, in order. * @returns Each call with its result, in order. */ -export async function recordClientSamples(): Promise { +export async function recordClientSamples( + calls: readonly SampleCall[] = CALLS, +): Promise { const mock = await startMockServer({ port: 0 }); try { + assertLoopback(mock.url); const samples: ClientSample[] = []; - for (const { key, call, run } of CALLS) { - const client = new PostcodeClient({ apiKey: key, baseUrl: mock.url }); - samples.push({ call, result: await run(client).catch(outcome) }); + for (const sample of calls) { + const client = new PostcodeClient({ apiKey: sample.key, baseUrl: mock.url }); + samples.push(await record(client, sample)); } return samples; } finally { diff --git a/test/site/playground.test.ts b/test/site/playground.test.ts index f3ebda1..3ae6ee2 100644 --- a/test/site/playground.test.ts +++ b/test/site/playground.test.ts @@ -7,7 +7,9 @@ const PLAYGROUND = builtFiles('.html').find(({ path }) => path === 'playground/i describe('the playground page', () => { it('shows the answers that the client gave to the mock gateway at build time', () => { - expect(PLAYGROUND?.text).toContain('SYNTHETIC LGA'); + for (const text of ['FC-01-Z99-ZZ-02', 'not_found', 'Z99', 'SYNTHETIC LGA']) { + expect(PLAYGROUND?.text).toContain(text); + } expect(PLAYGROUND?.text).toContain('insufficient_credits'); }); }); diff --git a/test/unit/client-samples.test.ts b/test/unit/client-samples.test.ts index bd87b1e..1c30340 100644 --- a/test/unit/client-samples.test.ts +++ b/test/unit/client-samples.test.ts @@ -1,7 +1,20 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; -import { recordClientSamples } from '../../scripts/client-samples.ts'; +import { describe, expect, it, vi } from 'vitest'; +import { recordClientSamples, type SampleCall } from '../../scripts/client-samples.ts'; + +const CODE = 'FC-01-Z99-ZZ-01'; +const NO_CREDITS_KEY = 'nipost_test_mock_no_credits'; + +function sample(over: Partial): SampleCall { + return { + key: 'nipost_test_mock_l3', + call: `await client.lookup('${CODE}')`, + expected: 'success', + run: (client) => client.lookup(CODE), + ...over, + }; +} describe('recordClientSamples', () => { it('records the answers of the client to the mock gateway, errors included', async () => { @@ -10,14 +23,58 @@ describe('recordClientSamples', () => { { valid: true, levelReceived: 1 }, { valid: true, levelReceived: 2, administrativeAddress: { lgaName: 'SYNTHETIC LGA' } }, { valid: false, status: 'not_found' }, - { found: true, unit: { postcode: { canonical: 'FC-01-Z99-ZZ-01' } } }, + { found: true, unit: { postcode: { canonical: CODE } } }, { segment: 'district', suggestions: [{ code: 'Z99' }] }, { name: 'PostcodeError', code: 'insufficient_credits', status: 402 }, ]); + expect(samples[0]?.call).toBe("await client.lookup('FC-01-Z99-ZZ-01')"); + }); + + it('fails when a sample expects an error code and the client returns another code', async () => { + const wrong = sample({ + key: NO_CREDITS_KEY, + call: 'a call that needs another code', + expected: 'rate_limited', + run: (client) => client.lookup(CODE, { level: 2 }), + }); + await expect(recordClientSamples([wrong])).rejects.toThrow( + 'Sample "a call that needs another code" expected rate_limited but got insufficient_credits.', + ); + }); + + it('fails when a sample expects success and the client throws', async () => { + const broken = sample({ + key: NO_CREDITS_KEY, + call: 'a call that must work', + run: (client) => client.lookup(CODE, { level: 2 }), + }); + await expect(recordClientSamples([broken])).rejects.toThrow( + 'Sample "a call that must work" expected success but got insufficient_credits.', + ); + }); + + it('fails when a sample expects an error and the client succeeds', async () => { + const quiet = sample({ call: 'a call that must fail', expected: 'insufficient_credits' }); + await expect(recordClientSamples([quiet])).rejects.toThrow( + 'Sample "a call that must fail" expected insufficient_credits but got success.', + ); }); - it('shows each call as the page prints it', async () => { - const [first] = await recordClientSamples(); - expect(first?.call).toBe("await client.lookup('FC-01-Z99-ZZ-01')"); + it('stops the mock gateway when a sample fails', async () => { + const urls: string[] = []; + const real = globalThis.fetch; + const spy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + urls.push(input instanceof Request ? input.url : String(input)); + return real(input, init); + }); + const failing = sample({ expected: 'rate_limited' }); + try { + await expect(recordClientSamples([failing])).rejects.toThrow('but got success'); + const [url] = urls; + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:/); + await expect(real(url ?? '')).rejects.toThrow(); + } finally { + spy.mockRestore(); + } }); }); From 6713d9ea526c74a711c3c6e63bd9ec5d7d049721 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:54:08 +0100 Subject: [PATCH 12/27] docs: add the TypeScript guides, and run each of their examples Developers start from a guide, not from the reference. The guides show how to read a postcode offline and how to ask the gateway about it. Each example runs in CI against the built packages and the mock gateway, and each result that an example shows is compared, so a page cannot show an answer that the SDK does not give. Each table sits in a labelled region that takes the focus, so a keyboard user can scroll it on a narrow screen. Signed-off-by: MobileMage --- astro.config.mjs | 3 +- src/content/docs/guides/typescript-client.md | 144 +++++++++++++++++++ src/content/docs/guides/typescript.md | 123 ++++++++++++++++ src/content/docs/index.mdx | 7 + src/content/docs/playground.mdx | 4 +- src/content/docs/start.md | 30 ++++ test/support/example-client.ts | 20 +++ test/support/examples.ts | 78 ++++++++++ test/unit/examples.test.ts | 98 +++++++++++++ 9 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 src/content/docs/guides/typescript-client.md create mode 100644 src/content/docs/guides/typescript.md create mode 100644 src/content/docs/start.md create mode 100644 test/support/example-client.ts create mode 100644 test/support/examples.ts create mode 100644 test/unit/examples.test.ts diff --git a/astro.config.mjs b/astro.config.mjs index fb6f25d..cd39144 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -76,7 +76,8 @@ export default defineConfig({ expressiveCode: { defaultProps: { wrap: true } }, plugins: [coreTypeDoc(typeDocOf('core')), clientTypeDoc(typeDocOf('client'))], sidebar: [ - { label: 'Start', items: ['playground'] }, + { label: 'Start', items: ['start', 'playground'] }, + { label: 'Guides', items: [{ autogenerate: { directory: 'guides' } }] }, { label: 'Spec', items: [{ autogenerate: { directory: 'spec' } }] }, { label: 'API reference', items: [coreTypeDocGroup, clientTypeDocGroup] }, { label: 'Project', items: ['privacy'] }, diff --git a/src/content/docs/guides/typescript-client.md b/src/content/docs/guides/typescript-client.md new file mode 100644 index 0000000..faaf043 --- /dev/null +++ b/src/content/docs/guides/typescript-client.md @@ -0,0 +1,144 @@ +--- +title: Look up postcodes in TypeScript +description: Ask NIPOST's gateway about a postcode, a place or a typed code with @gatepost/client. +sidebar: + label: TypeScript client + order: 2 +--- + +`@gatepost/client` calls NIPOST's postcode gateway with your own API key. It checks each postcode offline first, so a bad code sends no request. It retries a failed request when a retry can help. + +## Install + +```sh +pnpm add @gatepost/client +``` + +The package installs `@gatepost/core` too. Node 22 and current browsers have the `fetch` that the client uses. + +## Keep the key on a server + +The gateway refuses every call without a key. A key that starts with `nipost_test_` or `nipost_live_` is a secret. Keep it on a server, and read it from the environment. The client refuses a secret key in a web page. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const result = await client.lookup('fc 01 z99 zz 01'); +result.postcode.canonical; // 'FC-01-Z99-ZZ-01' +result.valid; // true +``` + +The examples on this page use `FC-01-Z99-ZZ-01`, a synthetic postcode. They show the answers of Gatepost's mock gateway, which runs them in CI. The real gateway does not know this postcode. + +## Look up a postcode + +A postcode that the gateway does not know is a result with `valid` set to false. It is not an error. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const unknown = await client.lookup('FC-01-Z99-ZZ-02'); +unknown.valid; // false +unknown.status; // 'not_found' +``` + +`lookup` asks for level 1 when you give no level. NIPOST's docs say that level 1 tells only whether a postcode exists. Level 2 adds the names of the places that hold it and a recent house address. Level 3 adds what the building is used for. Levels 2 and higher use credits, and your key must allow the level. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const result = await client.lookup('FC-01-Z99-ZZ-01', { level: 2 }); +result.levelReceived; // 2 +result.administrativeAddress?.lgaName; // 'SYNTHETIC LGA' +``` + +The gateway does not say which level it sent, so `levelReceived` comes from the fields of the response. + +## Handle errors + +Each call throws a `PostcodeError` when it cannot give a result. Its `code` tells you what to do. + +
    + +| Code | What to do | +| ------------------------------------------ | ----------------------------------------------------------------- | +| `invalid_input` | Ask the user to correct the postcode. The client sent no request. | +| `unauthorized` | Check that the key is present and correct. | +| `forbidden` | Ask for a lower level, or get a key that allows the level. | +| `origin_not_allowed` | Add the address of the page to the key in NIPOST's dashboard. | +| `insufficient_credits` | Use level 1, or ask NIPOST about credits for the level. | +| `rate_limited` | Wait for `retryAfterMs`, then try again. | +| `server_error`, `network_error`, `timeout` | Try again later. The client has already retried. | +| `unexpected_response` | Report it. The gateway sent a body that the client cannot read. | + +
    + +```ts +import { PostcodeClient, PostcodeError } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +try { + await client.lookup('900108'); +} catch (error) { + if (error instanceof PostcodeError) { + error.code; // 'invalid_input' + } +} +``` + +Show the user your own message for each code. The message of the error is for developers. + +## Find the postcode of a place + +`reverse` takes a latitude and a longitude in degrees. The gateway looks for a unit within 25 m, or within the radius that you give, up to 250 m. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const place = await client.reverse(9, 7, { maxDistanceM: 50 }); +place.found; // true +place.unit?.postcode.canonical; // 'FC-01-Z99-ZZ-01' +``` + +`found` can be true while `unit` is null. The gateway then found an area, but no unit within the radius. Before you show a unit, check the accuracy of the GPS fix with `precisionForAccuracy` from `@gatepost/core`. + +## Complete a postcode while the user types + +`autocomplete` takes the text that a user typed. It names the segment that the user is typing, and returns the gateway's values for it. Cancel the last call at each keystroke. + +```ts +import { PostcodeClient } from '@gatepost/client'; + +const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); +const keystroke = new AbortController(); +const typing = await client.autocomplete('fc 01 z', { signal: keystroke.signal }); +typing.segment; // 'district' +typing.suggestions[0]?.postcode?.canonical; // 'FC-01-Z99' +``` + +A cancelled call ends with the reason of the signal, not with a `PostcodeError`. + +## Choose the settings + +
    + +| Option | Meaning | Default | +| ------------ | ---------------------------------------- | ---------------------------------------- | +| `apiKey` | the key for the `X-API-Key` header | none | +| `baseUrl` | the address of the gateway | `https://api.postcode.gov.ng` | +| `timeoutMs` | the longest wait for one attempt | 8000 ms, and 15000 ms for `autocomplete` | +| `maxRetries` | the most retries after the first attempt | 2 | +| `cacheTtlMs` | how long the client keeps a result | 0, so it keeps none | + +
    + +The client sends at most 4 requests at a time, and identical calls share one request. It never logs, and none of its error messages holds your key. + +## Next steps + +- [Read the client contract](../../spec/client/) that every Gatepost client follows. +- [Read the API reference of `@gatepost/client`](../../reference/js/client/). diff --git a/src/content/docs/guides/typescript.md b/src/content/docs/guides/typescript.md new file mode 100644 index 0000000..9238980 --- /dev/null +++ b/src/content/docs/guides/typescript.md @@ -0,0 +1,123 @@ +--- +title: Check postcodes in TypeScript +description: Read, clean and format Nigeria's digital postcodes with @gatepost/core, with no network call. +sidebar: + label: TypeScript + order: 1 +--- + +`@gatepost/core` reads the postcodes that users type or paste. It runs in Node 22 or later and in current browsers. It makes no network call, so it needs no API key. + +## Install + +```sh +pnpm add @gatepost/core +``` + +## Read a postcode + +`parse` takes the text that a user typed, and returns a result. When `ok` is true, `value` holds the postcode in three forms. + +```ts +import { parse } from '@gatepost/core'; + +const result = parse('ek 01 a03 fk 01'); +if (result.ok) { + result.value.canonical; // 'EK-01-A03-FK-01' + result.value.display; // 'EK 01 A03 FK 01' + result.value.compact; // 'EK01A03FK01' +} +``` + +Store the compact or the canonical form, and show the display form to people. `parse` accepts spaces, hyphens, dashes and any letter case. It also accepts the full-width characters that some phone keyboards type. It never throws. + +## Tell the user what is wrong + +When `ok` is false, `error.code` names the first problem that `parse` found. + +
    + +| Code | Meaning | +| --------------- | -------------------------------------------------------- | +| `empty` | The text holds no letters or digits. | +| `legacy_code` | The text is an old 6-digit postcode. | +| `bad_character` | The text holds a character that no postcode has. | +| `bad_length` | The text has too many or too few characters. | +| `unknown_state` | The first two letters do not name a state. | +| `bad_segment` | One segment breaks its rule, for example a unit of `00`. | + +
    + +For `unknown_state` and `bad_segment`, `error.segment` names the segment. When a look-alike character causes the problem, `error.suggestion` holds the fixed postcode. + +```ts +import { parse } from '@gatepost/core'; + +const typo = parse('EK-O1-A03-FK-01'); +if (!typo.ok) { + typo.error.code; // 'bad_segment' + typo.error.segment; // 'lga' + typo.error.suggestion; // 'EK-01-A03-FK-01' +} +``` + +Show the suggestion as a question, and let the user accept it. `parse` never accepts the fixed code by itself. + +## Recognise an old postcode + +Many records still hold a legacy postcode of 6 digits. It names an area, not a building. `isLegacy` recognises one, so your form can ask for the new postcode. + +```ts +import { isLegacy } from '@gatepost/core'; + +isLegacy('900 108'); // true +isLegacy('EK-01-A03-FK-01'); // false +``` + +## Work with part of a postcode + +Set `allowPartial` to accept a postcode that stops after a segment. `precision` names the last segment that the postcode holds. `truncate`, `parent` and `contains` move between the segments. + +```ts +import { contains, parent, parse, truncate } from '@gatepost/core'; + +const building = parse('EK-01-A03-FK-01'); +const district = parse('EK 01 A03', { allowPartial: true }); +if (building.ok && district.ok) { + district.value.precision; // 'district' + truncate(building.value, 'area').canonical; // 'EK-01-A03-FK' + parent(building.value)?.canonical; // 'EK-01-A03-FK' + contains(district.value, building.value); // true +} +``` + +## Use a GPS fix + +A phone reports the accuracy of each GPS fix in metres. `precisionForAccuracy` gives the most precise segment that a fix of that accuracy supports. Do not show a unit for a fix that cannot tell two buildings apart. + +```ts +import { precisionForAccuracy } from '@gatepost/core'; + +precisionForAccuracy(6); // 'unit' +precisionForAccuracy(35); // 'district' +precisionForAccuracy(null); // 'lga' +``` + +## Keep postcodes out of logs + +A full postcode names one building. `redact` hides the unit, so a log shows the area and not the building. + +```ts +import { parse, redact } from '@gatepost/core'; + +const result = parse('EK-01-A03-FK-01'); +if (result.ok) { + redact(result.value); // 'EK-01-A03-FK-**' +} +``` + +## Next steps + +- [Look up a postcode with the gateway](../typescript-client/). +- [Try the parser in the playground](../../playground/). +- [Read the API reference of `@gatepost/core`](../../reference/js/core/). diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx index 1f8c0d1..d0b463f 100644 --- a/src/content/docs/index.mdx +++ b/src/content/docs/index.mdx @@ -4,6 +4,13 @@ description: Unofficial, open-source developer tools for Nigeria's digital postc template: splash hero: tagline: Open-source tools that read, check and look up Nigeria's digital postcodes. Unofficial. Not made or endorsed by NIPOST. + actions: + - text: Choose a tool + link: start/ + icon: right-arrow + - text: Try the playground + link: playground/ + variant: minimal --- On 1 October 2026, NIPOST launched a digital postcode for addressable buildings and locations in Nigeria. A postcode such as `EK-01-A03-FK-01` has five segments. From large to small, they are the state, the LGA, the district, the area and the unit. The unit is one building. diff --git a/src/content/docs/playground.mdx b/src/content/docs/playground.mdx index 22687d8..cebdd84 100644 --- a/src/content/docs/playground.mdx +++ b/src/content/docs/playground.mdx @@ -10,10 +10,10 @@ The playground runs `@gatepost/core` in your browser. It checks the form of the -The playground checks the form only. Only NIPOST's gateway can say whether a building has a postcode. +The playground checks the form only. Only NIPOST's gateway can say whether a building has a postcode. The [TypeScript guide](../guides/typescript/) shows the same functions in code. ## What the client returns -`@gatepost/client` needs a key and a network, so the playground does not run it. These are the answers that the client gave to Gatepost's mock gateway when this site was built. The answers use synthetic postcodes with the district `Z99`. +`@gatepost/client` needs a key and a network, so the playground does not run it. These are the answers that the client gave to Gatepost's mock gateway when this site was built. The answers use synthetic postcodes with the district `Z99`. The [TypeScript client guide](../guides/typescript-client/) explains each field. diff --git a/src/content/docs/start.md b/src/content/docs/start.md new file mode 100644 index 0000000..af84412 --- /dev/null +++ b/src/content/docs/start.md @@ -0,0 +1,30 @@ +--- +title: Choose a tool +description: Find the Gatepost package that fits your app, and learn what each one needs. +--- + +Each Gatepost tool follows the same spec, so each one reads a postcode in the same way. Choose by the language of your app and by what it must know. + +
    + +| You want to | Use | Needs a key | +| -------------------------------------------------------- | -------------------------------------------------- | ----------- | +| Check the form of a postcode in TypeScript or JavaScript | [`@gatepost/core`](../guides/typescript/) | no | +| Look up a postcode from a TypeScript server | [`@gatepost/client`](../guides/typescript-client/) | yes | + +
    + +## What a check of the form tells you + +The core packages read the text that a user typed. They tell you whether the text has the form of a postcode, and what is wrong when it does not. They work offline, so they cost nothing and send nothing. + +A postcode with the right form can still be wrong. Only NIPOST's gateway knows which postcodes exist. Check the form first, and then ask the gateway when your app needs to know more. + +## Get a key + +The gateway refuses every call without a key. Register on [NIPOST's dashboard](https://dashboard.postcode.gov.ng/register) to get one. NIPOST gives two kinds of key. + +- A secret key starts with `nipost_test_` or `nipost_live_`. Keep it on a server. +- A publishable key starts with `nipost_pk_test_` or `nipost_pk_live_`. A web page can hold it, but it works only from the addresses that you allow in the dashboard. + +Test keys work only on NIPOST's staging gateway. NIPOST's docs explain the levels, the credits and the limits: [docs.postcode.gov.ng](https://docs.postcode.gov.ng). diff --git a/test/support/example-client.ts b/test/support/example-client.ts new file mode 100644 index 0000000..4f194f9 --- /dev/null +++ b/test/support/example-client.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The examples on the pages call NIPOST's gateway. Their test imports this module in place of the +// package, so that each call goes to Gatepost's mock gateway with a mock key. +import process from 'node:process'; +import { PostcodeClient as GatewayClient, type ClientOptions } from '@gatepost/client'; + +export { PostcodeError } from '@gatepost/client'; + +/** A client of the mock gateway that `GATEPOST_MOCK_URL` names. */ +export class PostcodeClient extends GatewayClient { + constructor(options: ClientOptions = {}) { + const baseUrl = process.env['GATEPOST_MOCK_URL']; + // With no mock gateway, a call would go to NIPOST. A test must never call it. + if (baseUrl === undefined) { + throw new Error('Start the mock gateway before the examples run.'); + } + super({ ...options, baseUrl, apiKey: 'nipost_test_mock_l3' }); + } +} diff --git a/test/support/examples.ts b/test/support/examples.ts new file mode 100644 index 0000000..9d1928c --- /dev/null +++ b/test/support/examples.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { trackedPages } from '../../scripts/check-prose.ts'; + +/** A code block of a page: the page, the place of the block on it, its language and its code. */ +export interface Example { + readonly page: string; + readonly index: number; + readonly language: string; + readonly source: string; +} + +const FENCE = /^```(\w*)\n([\s\S]*?)^```$/gm; + +/** + * Finds the code blocks of a Markdown or MDX page. + * + * @param page - The path of the page, for the test report. + * @param text - The text of the page. + * @returns Each block, in the order of the page. + */ +export function examplesOf(page: string, text: string): readonly Example[] { + return Array.from(text.matchAll(FENCE), ([, language, source], index) => ({ + page, + index, + language: language ?? '', + source: source ?? '', + })); +} + +/** + * Reads the code blocks of each hand-written page that git tracks. The build writes the spec + * pages and the API reference, and their own repos run the examples in them. + * + * @returns Each block of each page. + */ +export function pageExamples(): readonly Example[] { + return trackedPages().flatMap((path) => examplesOf(path, readFileSync(path, 'utf8'))); +} + +// A line such as `result.valid; // true` shows a result. The module that runs the example turns +// it into a call of `shown`, which compares the value and counts the call. A guard such as +// `if (result.ok)` can skip a line, so the test compares the count with the number of lines. +const RESULT_LINE = /^(\s*)(.+);\s*\/\/ *('[^']*'|-?\d+(?:\.\d+)?|true|false|null|undefined)$/gm; + +/** + * Counts the lines of a TypeScript example that show a result. + * + * @param source - The code of the example. + * @returns The number of lines. + */ +export function countShownResults(source: string): number { + return Array.from(source.matchAll(RESULT_LINE)).length; +} + +/** + * Turns a TypeScript example into a module that checks each result that it shows. + * + * @param source - The code of the example. + * @param modules - The file that stands in for each package that the example imports. + * @returns The text of the module. It exports `checked`, the number of results that it compared. + */ +export function toCheckedModule(source: string, modules: Readonly>): string { + const imported = Object.entries(modules).reduce( + (code, [name, file]) => code.replaceAll(`'${name}'`, `'${file}'`), + source, + ); + return [ + "import { strictEqual } from 'node:assert/strict';", + 'export let checked = 0;', + 'function shown(actual: unknown, expected: unknown): void {', + ' strictEqual(actual, expected);', + ' checked += 1;', + '}', + imported.replace(RESULT_LINE, '$1shown($2, $3);'), + ].join('\n'); +} diff --git a/test/unit/examples.test.ts b/test/unit/examples.test.ts new file mode 100644 index 0000000..a701494 --- /dev/null +++ b/test/unit/examples.test.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { startMockServer, type MockServer } from '@gatepost/mock-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + countShownResults, + examplesOf, + pageExamples, + toCheckedModule, + type Example, +} from '../support/examples.ts'; + +const EXAMPLES = pageExamples(); +const MODULES = { + '@gatepost/client': fileURLToPath(new URL('../support/example-client.ts', import.meta.url)), +}; + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')); +} + +// The packages that an install command on a page may name. +const PNPM_PACKAGES = ['core', 'client'].map( + (name) => (readJson(`js/packages/${name}/package.json`) as { name: string }).name, +); + +// Each example with the name that the test report shows, such as `src/.../php.md block 2`. +function named(examples: readonly Example[]): readonly (readonly [string, Example])[] { + return examples.map((example) => [`${example.page} block ${String(example.index)}`, example]); +} + +function inLanguage(language: string): readonly (readonly [string, Example])[] { + return named(EXAMPLES.filter((example) => example.language === language)); +} + +describe('examplesOf', () => { + it('finds each fenced block with its language', () => { + const text = 'Intro.\n\n```ts\nconst a = 1;\n```\n\nMore.\n\n```sh\npnpm add x\n```\n'; + expect(examplesOf('page.md', text)).toEqual([ + { page: 'page.md', index: 0, language: 'ts', source: 'const a = 1;\n' }, + { page: 'page.md', index: 1, language: 'sh', source: 'pnpm add x\n' }, + ]); + }); +}); + +describe('toCheckedModule', () => { + it('compares each shown result and points each package at its stand-in', () => { + const source = "import { a } from '@gatepost/client';\nconst b = 2;\nb; // 2\n"; + expect(countShownResults(source)).toBe(1); + expect(toCheckedModule(source, { '@gatepost/client': '/x.ts' })).toContain( + "import { a } from '/x.ts';\nconst b = 2;\nshown(b, 2);", + ); + }); +}); + +describe('every code block on a page', () => { + it.each(named(EXAMPLES))('uses a language that a check runs: %s', (_, { language }) => { + expect(['ts', 'sh']).toContain(language); + }); +}); + +describe('every install command on a page', () => { + it.each(inLanguage('sh'))('names a Gatepost package: %s', (_, { source }) => { + const pnpm = Array.from(source.matchAll(/^pnpm add (\S+)$/gm), ([, name]) => name); + expect(pnpm.length).toBeGreaterThan(0); + for (const name of pnpm) expect(PNPM_PACKAGES).toContain(name); + }); +}); + +describe('every example on a page', () => { + let mock: MockServer; + let folder: string; + + beforeAll(async () => { + mock = await startMockServer({ port: 0 }); + process.env['GATEPOST_MOCK_URL'] = mock.url; + mkdirSync('temp', { recursive: true }); + folder = mkdtempSync(join(process.cwd(), 'temp', 'examples-')); + }); + + afterAll(async () => { + await mock.close(); + rmSync(folder, { recursive: true, force: true }); + }); + + it.each(inLanguage('ts'))('runs and shows only true results: %s', async (name, { source }) => { + const file = join(folder, `${name.replace(/\W+/g, '-')}.ts`); + writeFileSync(file, toCheckedModule(source, MODULES)); + const { checked } = (await import(pathToFileURL(file).href)) as { checked: number }; + expect(checked, 'the number of results that the example compared').toBe( + countShownResults(source), + ); + }); +}); From c746f39850ef1447b88f99e677cf515142542d54 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 13:57:52 +0100 Subject: [PATCH 13/27] docs: add the PHP guide and the API reference of the PHP package PHP developers need the same guide and reference as TypeScript ones. The guide's examples run with PHP against the mock gateway, and a small reflection script writes the reference from the doc comments, in the theme and the checks of the site. A test compares the pages with the API dump of the PHP repo, so a public class without a page fails. Signed-off-by: MobileMage --- .github/workflows/ci.yml | 6 + .github/workflows/pages.yml | 6 + astro.config.mjs | 19 +- package.json | 6 +- scripts/php-reference.php | 301 ++++++++++++++++++++++++++++++++ src/content/docs/guides/php.md | 116 ++++++++++++ src/content/docs/start.md | 9 +- test/support/examples.ts | 28 +++ test/unit/examples.test.ts | 56 +++++- test/unit/php-reference.test.ts | 60 +++++++ 10 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 scripts/php-reference.php create mode 100644 src/content/docs/guides/php.md create mode 100644 test/unit/php-reference.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9357a5..536d78c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: with: # pnpm check starts reuse and zizmor with uvx. No lock file keys a cache. enable-cache: false + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + # The newest PHP. The PHP package supports 8.1 and later, and its own CI tests 8.1. + php-version: '8.4' + tools: composer + coverage: none - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm sdk diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9f240c2..ac136c1 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -27,6 +27,12 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + # The newest PHP. The PHP package supports 8.1 and later, and its own CI tests 8.1. + php-version: '8.4' + tools: composer + coverage: none - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm sdk diff --git a/astro.config.mjs b/astro.config.mjs index cd39144..30a7599 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from 'node:child_process'; import starlight from '@astrojs/starlight'; import { defineConfig } from 'astro/config'; import { createStarlightTypeDocPlugin } from 'starlight-typedoc'; @@ -41,6 +42,11 @@ export default defineConfig({ 'astro:config:setup': async () => { writeSpecPages(import.meta.dirname); await writeClientSamples(import.meta.dirname); + execFileSync( + 'php', + ['scripts/php-reference.php', 'php', 'src/content/docs/reference/php'], + { cwd: import.meta.dirname }, + ); }, }, }, @@ -79,7 +85,18 @@ export default defineConfig({ { label: 'Start', items: ['start', 'playground'] }, { label: 'Guides', items: [{ autogenerate: { directory: 'guides' } }] }, { label: 'Spec', items: [{ autogenerate: { directory: 'spec' } }] }, - { label: 'API reference', items: [coreTypeDocGroup, clientTypeDocGroup] }, + { + label: 'API reference', + items: [ + coreTypeDocGroup, + clientTypeDocGroup, + { + label: 'gatepost/postcode', + collapsed: true, + items: [{ autogenerate: { directory: 'reference/php' } }], + }, + ], + }, { label: 'Project', items: ['privacy'] }, ], }), diff --git a/package.json b/package.json index 39b8dd4..b240a48 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,22 @@ "node": ">=24.15.0" }, "scripts": { - "sdk": "run-s sdk:js sdk:js:build", + "sdk": "run-s sdk:js sdk:js:build sdk:php", "sdk:js": "pnpm --dir js install --frozen-lockfile", "sdk:js:build": "pnpm --dir js --filter @gatepost/core --filter @gatepost/client run build", + "sdk:php": "composer --working-dir=php install --no-interaction --no-progress", "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev", "build": "ASTRO_TELEMETRY_DISABLED=1 astro build", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", "check": "run-s check:source check:site check:policy", - "check:source": "run-s format:check lint typecheck test", + "check:source": "run-s format:check lint typecheck php:check test", "check:site": "run-s build test:site test:browser", "check:policy": "run-s tells prose context:check licences reuse:check workflows:check", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . --max-warnings 0", "typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check", + "php:check": "php php/vendor/bin/phpstan analyse --no-progress --level max scripts", "test": "vitest run --project unit --coverage", "test:site": "vitest run --project site", "tells": "python3 spec/scripts/check-tells", diff --git a/scripts/php-reference.php b/scripts/php-reference.php new file mode 100644 index 0000000..4d45c9b --- /dev/null +++ b/scripts/php-reference.php @@ -0,0 +1,301 @@ + + +namespace Gatepost\Docs; + +use ReflectionClass; +use ReflectionClassConstant; +use ReflectionEnum; +use ReflectionEnumBackedCase; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; +use ReflectionType; +use RuntimeException; + +const PREFIX = 'Gatepost\\Postcode\\'; + +/** + * @return array{string, list} The description, and each tag with its text. + */ +function readDocComment(string|false $comment): array +{ + if ($comment === false) { + return ['', []]; + } + $lines = \explode("\n", \trim(\substr($comment, 3, -2))); + $lines = \array_map(static fn(string $line): string => \preg_replace('/^\s*\* ?/', '', $line) + ?? $line, $lines); + $description = []; + $tags = []; + foreach ($lines as $line) { + if (\preg_match('/^@(\w+)\s*(.*)$/', $line, $tag) === 1) { + $tags[] = [$tag[1], $tag[2]]; + } elseif ($tags !== []) { + $last = \array_key_last($tags); + $tags[$last][1] .= ' ' . \trim($line); + } else { + $description[] = $line; + } + } + + return [\trim(\implode("\n", $description)), $tags]; +} + +function isInternal(string|false $comment): bool +{ + return $comment !== false && \str_contains($comment, '@internal'); +} + +function typeName(?ReflectionType $type): string +{ + $name = (string) $type; + + return \str_replace(PREFIX, '', $name); +} + +function isInternalType(?ReflectionType $type): bool +{ + return $type instanceof ReflectionNamedType && \str_contains($type->getName(), '\\Internal\\'); +} + +// A default value or a constant as PHP code. The package uses scalars and null only. +function literal(mixed $value): string +{ + return match (true) { + $value === null => 'null', + \is_bool($value) => $value ? 'true' : 'false', + \is_int($value), \is_float($value) => (string) $value, + \is_string($value) => "'" . \addcslashes($value, "'\\") . "'", + default => throw new RuntimeException('A default value is not a scalar.'), + }; +} + +function parameterText(ReflectionParameter $parameter): string +{ + $text = \trim(typeName($parameter->getType()) . ' $' . $parameter->getName()); + + return $parameter->isDefaultValueAvailable() + ? $text . ' = ' . literal($parameter->getDefaultValue()) + : $text; +} + +/** + * @return list The parameters that callers use. A parameter of an internal + * type is a seam for the tests of the package. + */ +function publicParameters(ReflectionMethod $method): array +{ + return \array_values(\array_filter( + $method->getParameters(), + static fn(ReflectionParameter $parameter): bool => !isInternalType($parameter->getType()), + )); +} + +/** + * @return list + */ +function methodLines(ReflectionMethod $method): array +{ + [$description, $tags] = readDocComment($method->getDocComment()); + $parameters = \array_map(parameterText(...), publicParameters($method)); + $static = $method->isStatic() ? 'static ' : ''; + $return = $method->hasReturnType() ? ': ' . typeName($method->getReturnType()) : ''; + $head = "public {$static}function {$method->getName()}("; + $signature = $head . \implode(', ', $parameters) . "){$return}"; + // A long signature puts each parameter on its own line, as the PER coding style does. + if (\strlen($signature) > 100) { + $signature = $head . "\n " . \implode(",\n ", $parameters) . ",\n){$return}"; + } + $lines = ["### {$method->getName()}()", '', '```php', $signature, '```', '']; + if ($description !== '') { + $lines = [...$lines, $description, '']; + } + + return [...$lines, ...tagLines($tags, publicParameters($method))]; +} + +/** + * @param list $tags + * @param list $parameters + * + * @return list + */ +function tagLines(array $tags, array $parameters): array +{ + $names = \array_map(static fn(ReflectionParameter $p): string => $p->getName(), $parameters); + $rows = []; + $throws = []; + foreach ($tags as [$tag, $text]) { + $text = \trim((string) \preg_replace('/\s+/', ' ', $text)); + if ($tag === 'param' && \preg_match('/^\S+\s+\$(\w+)\s*(.*)$/', $text, $param) === 1) { + if (\in_array($param[1], $names, true)) { + $rows[] = "| `\${$param[1]}` | {$param[2]} |"; + } + } elseif ($tag === 'throws') { + $throws[] = "- {$text}"; + } + } + $lines = $rows === [] ? [] : ['#### Parameters', '', '| Name | Meaning |', '| --- | --- |']; + $lines = $rows === [] ? [] : [...$lines, ...$rows, '']; + + return $throws === [] ? $lines : [...$lines, '#### Throws', '', ...$throws, '']; +} + +/** + * @return list + */ +function memberLines(ReflectionClassConstant|ReflectionProperty $member, string $code): array +{ + [$description] = readDocComment($member->getDocComment()); + $meaning = $description === '' ? '' : ': ' . \str_replace("\n", ' ', $description); + + return ["- `{$code}`{$meaning}"]; +} + +/** + * @param ReflectionClass $class + * + * @return list + */ +function classLines(ReflectionClass $class): array +{ + [$description] = readDocComment($class->getDocComment()); + $short = $class->getShortName(); + $first = \preg_split('/(?<=\.)\s/', \str_replace("\n", ' ', $description))[0] ?? ''; + $lines = [ + '---', + "title: {$short}", + 'description: ' . \json_encode($first, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), + 'editUrl: false', + '---', + '', + "`{$class->getName()}`", + '', + $description, + '', + ]; + $members = []; + if ($class instanceof ReflectionEnum) { + foreach ($class->getCases() as $case) { + $value = $case instanceof ReflectionEnumBackedCase + ? ' = ' . literal($case->getBackingValue()) + : ''; + $members = [...$members, ...memberLines($case, $case->getName() . $value)]; + } + } + foreach ($class->getReflectionConstants(ReflectionClassConstant::IS_PUBLIC) as $constant) { + if (!$constant->isEnumCase()) { + $code = $constant->getName() . ' = ' . literal($constant->getValue()); + $members = [...$members, ...memberLines($constant, $code)]; + } + } + // An enum has the properties name and value, which the cases already show. + $properties = $class->isEnum() ? [] : $class->getProperties(ReflectionProperty::IS_PUBLIC); + foreach ($properties as $property) { + $code = typeName($property->getType()) . ' $' . $property->getName(); + $members = [...$members, ...memberLines($property, $code)]; + } + if ($members !== []) { + $lines = [...$lines, '## Members', '', ...$members, '']; + } + $methods = \array_filter( + $class->getMethods(ReflectionMethod::IS_PUBLIC), + static fn(ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() + === $class->getName() && !isInternal($method->getDocComment()) + && !\in_array($method->getName(), ['cases', 'from', 'tryFrom'], true), + ); + if ($methods !== []) { + $lines[] = '## Methods'; + $lines[] = ''; + foreach ($methods as $method) { + $lines = [...$lines, ...methodLines($method)]; + } + } + + return $lines; +} + +/** + * @return list> The public classes, enums and interfaces, by name. + */ +function publicClasses(string $source): array +{ + \spl_autoload_register(static function (string $name) use ($source): void { + $file = $source . '/' . \str_replace('\\', '/', \substr($name, \strlen(PREFIX))) . '.php'; + if (\str_starts_with($name, PREFIX) && \is_file($file)) { + require_once $file; + } + }); + $classes = []; + $files = \glob($source . '/{,*/}*.php', \GLOB_BRACE); + foreach ($files === false ? [] : $files as $file) { + $path = \substr($file, \strlen($source) + 1, -4); + $name = PREFIX . \str_replace('/', '\\', $path); + $exists = \class_exists($name) || \interface_exists($name) || \enum_exists($name); + if (!$exists) { + throw new RuntimeException("{$file} holds no class named {$name}."); + } + $class = \enum_exists($name) ? new ReflectionEnum($name) : new ReflectionClass($name); + if (!\str_contains($path, 'Internal') && !isInternal($class->getDocComment())) { + $classes[$name] = $class; + } + } + \ksort($classes); + + return \array_values($classes); +} + +function slugOf(string $name): string +{ + return \strtolower(\str_replace('\\', '-', \substr($name, \strlen(PREFIX)))); +} + +/** + * @param list $arguments + */ +function writeReference(array $arguments): int +{ + if (\count($arguments) !== 2) { + \fwrite(\STDERR, "Usage: php scripts/php-reference.php \n"); + + return 2; + } + [$package, $output] = $arguments; + $classes = publicClasses($package . '/src'); + if (!\is_dir($output) && !\mkdir($output, 0o777, true)) { + throw new RuntimeException("Cannot make {$output}."); + } + $index = [ + '---', + 'title: gatepost/postcode', + 'description: The public classes and enums of the PHP package.', + 'editUrl: false', + '---', + '', + 'The doc comments of the PHP package give this reference. Each page lists the public', + 'members of one class or enum.', + '', + ]; + foreach ($classes as $class) { + $name = $class->getName(); + $text = \implode("\n", classLines($class)) . "\n"; + \file_put_contents($output . '/' . slugOf($name) . '.md', $text); + $index[] = '- [`' . \substr($name, \strlen(PREFIX)) . '`](' . slugOf($name) . '/)'; + } + \file_put_contents($output . '/index.md', \implode("\n", $index) . "\n"); + + return 0; +} + +exit(writeReference(\array_slice($argv, 1))); diff --git a/src/content/docs/guides/php.md b/src/content/docs/guides/php.md new file mode 100644 index 0000000..e7f6831 --- /dev/null +++ b/src/content/docs/guides/php.md @@ -0,0 +1,116 @@ +--- +title: Check and look up postcodes in PHP +description: Read Nigeria's digital postcodes offline and query NIPOST's gateway with gatepost/postcode. +sidebar: + label: PHP + order: 3 +--- + +`gatepost/postcode` reads the postcodes that users type or paste, with no network call. Its client queries NIPOST's gateway with your own API key. It needs PHP 8.1 or later, and it does not need the intl or mbstring extensions. + +## Install + +```sh +composer require gatepost/postcode +``` + +## Read a postcode + +`Postcode::parse` takes the text that a user typed, and returns a result. It never throws. + +```php +isOk()) { + echo $result->value->canonical, "\n"; // EK-01-A03-FK-01 + echo $result->value->display, "\n"; // EK 01 A03 FK 01 +} +``` + +When `isOk()` is false, `error->code` names the first problem that `parse` found. The codes are the same in every Gatepost SDK. The [TypeScript guide](../typescript/#tell-the-user-what-is-wrong) lists them. For a typo with a look-alike character, `error->suggestion` holds the fixed postcode. + +```php +error?->code->value, "\n"; // bad_segment +echo $typo->error?->suggestion, "\n"; // EK-01-A03-FK-01 +``` + +Show the suggestion as a question, and let the user accept it. + +## Look up a postcode + +The client needs a PSR-18 HTTP client and a PSR-17 request factory, such as Guzzle 7. + +```sh +composer require guzzlehttp/guzzle +``` + +Keep the key on a server, and read it from the environment. Set the timeout of your HTTP client, and pass the same value as `timeoutMs`. PSR-18 has no timeout of its own, so the client relies on the timeout of the HTTP client. One `timeoutMs` applies to every call. When you pass none, the client waits 8000 ms, and 15000 ms for `autocomplete`. + +```php + 15]), + new HttpFactory(), + apiKey: getenv('NIPOST_API_KEY') ?: null, + timeoutMs: 15000, +); + +try { + $result = $client->lookup('FC-01-Z99-ZZ-01'); + echo $result->valid ? 'valid' : 'not found', "\n"; // valid +} catch (PostcodeException $error) { + echo $error->errorCode()->value, "\n"; +} +``` + +An empty string for `apiKey` throws an `InvalidArgumentException`. With `null`, the client sends no key, and the gateway answers 401. + +The examples on this page use `FC-01-Z99-ZZ-01`, a synthetic postcode. They show the answers of Gatepost's mock gateway, which runs them in CI. The real gateway does not know this postcode. + +`lookup` checks the postcode with `Postcode::parse` first, and sends no request for a bad postcode. Pass `level: 2` for more data, when your key allows it. Each failed call throws a `PostcodeException`. Its `errorCode()` gives the same codes as the [TypeScript client](../typescript-client/#handle-errors). + +## Find a place and complete a postcode + +`reverse` finds the postcode at a latitude and a longitude. `autocomplete` names the segment that the user is typing, and returns the gateway's values for it. + +```php + 15]), + new HttpFactory(), + apiKey: getenv('NIPOST_API_KEY') ?: null, + timeoutMs: 15000, +); + +$place = $client->reverse(9.0, 7.0); +echo $place->unit?->postcode->canonical, "\n"; // FC-01-Z99-ZZ-01 + +$typing = $client->autocomplete('fc 01 z'); +echo $typing->segment->value, "\n"; // district +``` + +The client is synchronous. Each call waits for its result, and the client sends one request at a time. + +## Next steps + +- [Read the API reference of `gatepost/postcode`](../../reference/php/). +- [Read the postcode grammar](../../spec/grammar/) that every SDK follows. diff --git a/src/content/docs/start.md b/src/content/docs/start.md index af84412..44a0b53 100644 --- a/src/content/docs/start.md +++ b/src/content/docs/start.md @@ -7,10 +7,11 @@ Each Gatepost tool follows the same spec, so each one reads a postcode in the sa
    -| You want to | Use | Needs a key | -| -------------------------------------------------------- | -------------------------------------------------- | ----------- | -| Check the form of a postcode in TypeScript or JavaScript | [`@gatepost/core`](../guides/typescript/) | no | -| Look up a postcode from a TypeScript server | [`@gatepost/client`](../guides/typescript-client/) | yes | +| You want to | Use | Needs a key | +| -------------------------------------------------------- | -------------------------------------------------- | ---------------- | +| Check the form of a postcode in TypeScript or JavaScript | [`@gatepost/core`](../guides/typescript/) | no | +| Look up a postcode from a TypeScript server | [`@gatepost/client`](../guides/typescript-client/) | yes | +| Check or look up a postcode in PHP | [`gatepost/postcode`](../guides/php/) | only for lookups |
    diff --git a/test/support/examples.ts b/test/support/examples.ts index 9d1928c..d5ad88d 100644 --- a/test/support/examples.ts +++ b/test/support/examples.ts @@ -76,3 +76,31 @@ export function toCheckedModule(source: string, modules: Readonlycanonical, "\n"; // EK-01-A03-FK-01` shows the line that it +// prints. The test compares the whole output of the example with these lines, in order. +const PHP_SHOWN_LINE = /^\s*echo .+; \/\/ (.+)$/gm; + +/** + * Lists the output lines that a PHP example shows in its comments. + * + * @param source - The code of the example. + * @returns The lines, in order. + */ +export function phpShownOutput(source: string): readonly string[] { + return Array.from(source.matchAll(PHP_SHOWN_LINE), ([, line]) => line ?? ''); +} + +const FACTORY_LINE = ' new HttpFactory(),\n'; + +/** + * Points the client of a PHP example at the mock gateway. The page shows the client with + * NIPOST's address, so the test adds one named argument after the request factory. + * + * @param source - The code of the example. + * @param baseUrl - The address of the mock gateway. + * @returns The code that the test runs. + */ +export function withMockGateway(source: string, baseUrl: string): string { + return source.replace(FACTORY_LINE, `${FACTORY_LINE} baseUrl: '${baseUrl}',\n`); +} diff --git a/test/unit/examples.test.ts b/test/unit/examples.test.ts index a701494..4108e20 100644 --- a/test/unit/examples.test.ts +++ b/test/unit/examples.test.ts @@ -1,16 +1,20 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 +import { execFile } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; import { startMockServer, type MockServer } from '@gatepost/mock-server'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { countShownResults, examplesOf, pageExamples, + phpShownOutput, toCheckedModule, + withMockGateway, type Example, } from '../support/examples.ts'; @@ -18,15 +22,24 @@ const EXAMPLES = pageExamples(); const MODULES = { '@gatepost/client': fileURLToPath(new URL('../support/example-client.ts', import.meta.url)), }; +// The mock gateway runs in this process, so PHP must run without blocking it. +const run = promisify(execFile); +const PHP_AUTOLOAD = join(process.cwd(), 'php/vendor/autoload.php'); function readJson(path: string): unknown { return JSON.parse(readFileSync(path, 'utf8')); } -// The packages that an install command on a page may name. +// The packages that an install command on a page may name: the Gatepost packages, and the HTTP +// client that the PHP package tests with. const PNPM_PACKAGES = ['core', 'client'].map( (name) => (readJson(`js/packages/${name}/package.json`) as { name: string }).name, ); +const PHP_MANIFEST = readJson('php/composer.json') as { + name: string; + 'require-dev': Record; +}; +const COMPOSER_PACKAGES = [PHP_MANIFEST.name, 'guzzlehttp/guzzle']; // Each example with the name that the test report shows, such as `src/.../php.md block 2`. function named(examples: readonly Example[]): readonly (readonly [string, Example])[] { @@ -57,17 +70,42 @@ describe('toCheckedModule', () => { }); }); +describe('phpShownOutput', () => { + it('lists the lines that echo statements show, in order', () => { + const source = 'echo $a, "\\n"; // one\n echo 1; // two\n$b = 2; // not shown\n'; + expect(phpShownOutput(source)).toEqual(['one', 'two']); + }); +}); + +describe('withMockGateway', () => { + it('adds the address of the mock gateway after the request factory', () => { + const source = 'new PostcodeClient(\n $http,\n new HttpFactory(),\n timeoutMs: 1,\n);'; + expect(withMockGateway(source, 'http://127.0.0.1:1')).toContain( + " new HttpFactory(),\n baseUrl: 'http://127.0.0.1:1',\n timeoutMs: 1,", + ); + }); + + it('leaves a source without that line as it is', () => { + expect(withMockGateway('echo 1;', 'http://127.0.0.1:1')).toBe('echo 1;'); + }); +}); + describe('every code block on a page', () => { it.each(named(EXAMPLES))('uses a language that a check runs: %s', (_, { language }) => { - expect(['ts', 'sh']).toContain(language); + expect(['ts', 'php', 'sh']).toContain(language); }); }); describe('every install command on a page', () => { it.each(inLanguage('sh'))('names a Gatepost package: %s', (_, { source }) => { const pnpm = Array.from(source.matchAll(/^pnpm add (\S+)$/gm), ([, name]) => name); - expect(pnpm.length).toBeGreaterThan(0); + const composer = Array.from(source.matchAll(/^composer require (\S+)$/gm), ([, name]) => name); + expect(pnpm.length + composer.length).toBeGreaterThan(0); for (const name of pnpm) expect(PNPM_PACKAGES).toContain(name); + for (const name of composer) expect(COMPOSER_PACKAGES).toContain(name); + expect(COMPOSER_PACKAGES.slice(1).every((name) => name in PHP_MANIFEST['require-dev'])).toBe( + true, + ); }); }); @@ -95,4 +133,16 @@ describe('every example on a page', () => { countShownResults(source), ); }); + + it.each(inLanguage('php'))( + 'runs and prints only the lines it shows: %s', + async (name, { source }) => { + const file = join(folder, `${name.replace(/\W+/g, '-')}.php`); + writeFileSync(file, withMockGateway(source, mock.url)); + const { stdout } = await run('php', ['-d', `auto_prepend_file=${PHP_AUTOLOAD}`, file], { + env: { ...process.env, NIPOST_API_KEY: 'nipost_test_mock_l3' }, + }); + expect(stdout.split('\n').slice(0, -1)).toEqual(phpShownOutput(source)); + }, + ); }); diff --git a/test/unit/php-reference.test.ts b/test/unit/php-reference.test.ts new file mode 100644 index 0000000..6167b7a --- /dev/null +++ b/test/unit/php-reference.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { execFile } from 'node:child_process'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const run = promisify(execFile); +const SCRIPT = 'scripts/php-reference.php'; +let folder: string; + +function page(name: string): string { + return readFileSync(join(folder, `${name}.md`), 'utf8'); +} + +beforeAll(async () => { + folder = mkdtempSync(join(tmpdir(), 'php-reference-')); + await run('php', [SCRIPT, 'php', folder]); +}); + +afterAll(() => { + rmSync(folder, { recursive: true, force: true }); +}); + +describe('the PHP reference', () => { + it('has one page for each class and enum of the API dump, and an index', () => { + const dump = readFileSync('php/docs/api-dump.md', 'utf8'); + const names = Array.from(dump.matchAll(/^(?:final class|enum) (\w+)/gm), ([, name]) => name); + const pages = readdirSync(folder).map((file) => file.replace(/\.md$/, '')); + expect(pages.map((slug) => slug.replace(/^client-/, '')).sort()).toEqual( + ['index', ...names.map((name) => name?.toLowerCase())].sort(), + ); + }); + + it('shows each method with its signature, its doc comment and its parameters', () => { + const postcode = page('postcode'); + expect(postcode).toContain('### parse()\n\n```php\n'); + expect(postcode).toContain( + 'public static function parse(string $input, bool $allowPartial = false): ParseResult', + ); + expect(postcode).toContain("echo Postcode::normalize(' ek-01 a03.fk-01 '); // EK01A03FK01"); + expect(postcode).toMatch(/\| `\$allowPartial` \| Also accept a code that stops after/); + }); + + it('leaves out the test seam of the client, which has an internal type', () => { + const client = page('client-postcodeclient'); + expect(client).toContain('?Psr\\SimpleCache\\CacheInterface $cache = null,\n)'); + expect(client).not.toContain('Timer'); + }); + + it('lists the cases of an enum with their values', () => { + expect(page('client-errorcode')).toContain("- `RateLimited = 'rate_limited'`"); + }); + + it('refuses a call without two folders', async () => { + await expect(run('php', [SCRIPT])).rejects.toMatchObject({ code: 2 }); + }); +}); From 6a98ce2e40a3e65db283917581ab57283fac8240 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:02:10 +0100 Subject: [PATCH 14/27] fix: make the example checks fail on every block they skip The extractor found only plain fences, so a fence with a title, a tilde fence or a fence in a list showed code that no test ran. It now finds all of them and fails any block that has text after the language, has no closing fence or is in a language with no check. A block that cannot run carries the mark "notrun". A result comment that the comparer cannot read, such as a double-quoted string or an array, now fails too. The install check needs every line of a block to be an install command. The test also type-checks each TypeScript example, and the paths no longer depend on the working directory. The client guide now says what the client retries: a 502, 503 or 504, a network error, and a timeout of lookup and reverse. The guide shows a cancelled autocomplete call. The search test waits 15 s, because Pagefind loads cold under parallel test load. Signed-off-by: MobileMage --- src/content/docs/guides/typescript-client.md | 31 ++-- test/browser/privacy.spec.ts | 3 +- test/support/examples.ts | 98 ++++++++++-- test/unit/examples.test.ts | 155 +++++++++++++++++-- 4 files changed, 254 insertions(+), 33 deletions(-) diff --git a/src/content/docs/guides/typescript-client.md b/src/content/docs/guides/typescript-client.md index faaf043..2c44e96 100644 --- a/src/content/docs/guides/typescript-client.md +++ b/src/content/docs/guides/typescript-client.md @@ -63,16 +63,18 @@ Each call throws a `PostcodeError` when it cannot give a result. Its `code` tell
    -| Code | What to do | -| ------------------------------------------ | ----------------------------------------------------------------- | -| `invalid_input` | Ask the user to correct the postcode. The client sent no request. | -| `unauthorized` | Check that the key is present and correct. | -| `forbidden` | Ask for a lower level, or get a key that allows the level. | -| `origin_not_allowed` | Add the address of the page to the key in NIPOST's dashboard. | -| `insufficient_credits` | Use level 1, or ask NIPOST about credits for the level. | -| `rate_limited` | Wait for `retryAfterMs`, then try again. | -| `server_error`, `network_error`, `timeout` | Try again later. The client has already retried. | -| `unexpected_response` | Report it. The gateway sent a body that the client cannot read. | +| Code | What to do | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `invalid_input` | Ask the user to correct the postcode. The client sends no request for a bad postcode. The code also covers a 4xx response that no other code names. | +| `unauthorized` | Check that the key is present and correct. | +| `forbidden` | Ask for a lower level, or get a key that allows the level. | +| `origin_not_allowed` | Add the address of the page to the key in NIPOST's dashboard. | +| `insufficient_credits` | Use level 1, or ask NIPOST about credits for the level. | +| `rate_limited` | Wait for `retryAfterMs` when it is not null, then try again. The client already retried a wait of 10 s or less. | +| `server_error` | Try again later. The client retries a 502, 503 or 504 only. Any other 5xx ends the call at once. | +| `network_error` | Check the network, then try again later. The client has already retried. | +| `timeout` | Try again, or raise `timeoutMs`. The client retries `lookup` and `reverse`, but not `autocomplete`. | +| `unexpected_response` | Report it. The gateway sent a body that the client cannot read. The client does not retry. |
    @@ -108,16 +110,19 @@ place.unit?.postcode.canonical; // 'FC-01-Z99-ZZ-01' ## Complete a postcode while the user types -`autocomplete` takes the text that a user typed. It names the segment that the user is typing, and returns the gateway's values for it. Cancel the last call at each keystroke. +`autocomplete` takes the text that a user typed. It names the segment that the user is typing, and returns the gateway's values for it. Cancel the last call at each keystroke, as the example does with `abort`. ```ts import { PostcodeClient } from '@gatepost/client'; const client = new PostcodeClient({ apiKey: process.env['NIPOST_API_KEY'] }); -const keystroke = new AbortController(); -const typing = await client.autocomplete('fc 01 z', { signal: keystroke.signal }); +const previous = new AbortController(); +const stale = client.autocomplete('fc 01', { signal: previous.signal }).catch(() => null); +previous.abort(); +const typing = await client.autocomplete('fc 01 z'); typing.segment; // 'district' typing.suggestions[0]?.postcode?.canonical; // 'FC-01-Z99' +await stale; // null ``` A cancelled call ends with the reason of the signal, not with a `PostcodeError`. diff --git a/test/browser/privacy.spec.ts b/test/browser/privacy.spec.ts index a9af665..2253996 100644 --- a/test/browser/privacy.spec.ts +++ b/test/browser/privacy.spec.ts @@ -23,7 +23,8 @@ test('a search sends the text nowhere and keeps no cookie', async ({ page, baseU await page.getByRole('button', { name: 'Search' }).click(); const search = page.getByRole('dialog', { name: 'Search' }); await search.getByRole('textbox', { name: 'Search' }).fill('postcode'); - await expect(search.getByRole('link').first()).toBeVisible(); + // Pagefind loads cold under parallel test load, so the default 5 s can run out. + await expect(search.getByRole('link').first()).toBeVisible({ timeout: 15_000 }); expect([...origins]).toEqual([baseURL]); expect(await page.context().cookies()).toEqual([]); }); diff --git a/test/support/examples.ts b/test/support/examples.ts index d5ad88d..08a6ac1 100644 --- a/test/support/examples.ts +++ b/test/support/examples.ts @@ -8,25 +8,63 @@ export interface Example { readonly page: string; readonly index: number; readonly language: string; + /** What follows the language on the opening fence, such as `title="a.ts"` or `notrun`. */ + readonly attributes: string; readonly source: string; + /** False when the page ends before the closing fence. */ + readonly closed: boolean; } -const FENCE = /^```(\w*)\n([\s\S]*?)^```$/gm; +/** The mark for a block that cannot run, written after the language: a fence of `ts notrun`. */ +export const NOT_RUN = 'notrun'; + +const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})(.*)$/; + +// A closing fence repeats the character of the opening fence, at least as many times. +function closes(line: string, fence: string): boolean { + const text = line.trim(); + return text.length >= fence.length && text === fence.charAt(0).repeat(text.length); +} /** - * Finds the code blocks of a Markdown or MDX page. + * Finds the code blocks of a Markdown or MDX page. A fence of three backticks or three tildes + * opens a block, with any indent and any text after the language. * * @param page - The path of the page, for the test report. * @param text - The text of the page. - * @returns Each block, in the order of the page. + * @returns Each block, in the order of the page. The code has the indent of its fence removed. */ export function examplesOf(page: string, text: string): readonly Example[] { - return Array.from(text.matchAll(FENCE), ([, language, source], index) => ({ - page, - index, - language: language ?? '', - source: source ?? '', - })); + const examples: Example[] = []; + let open: { indent: string; fence: string; info: string; lines: string[] } | undefined; + const finish = (closed: boolean): void => { + if (open === undefined) return; + const [language = '', ...rest] = open.info.trim().split(/\s+/); + const source = open.lines.map((line) => `${line}\n`).join(''); + examples.push({ + page, + index: examples.length, + language, + attributes: rest.join(' '), + source, + closed, + }); + open = undefined; + }; + for (const line of text.split('\n')) { + if (open === undefined) { + const [, indent = '', fence = '', info = ''] = OPENING_FENCE.exec(line) ?? []; + if (fence !== '') open = { indent, fence, info, lines: [] }; + } else if (closes(line, open.fence)) { + finish(true); + } else { + open.lines.push( + line.startsWith(open.indent) ? line.slice(open.indent.length) : line.trimStart(), + ); + } + } + finish(false); + return examples; } /** @@ -44,6 +82,22 @@ export function pageExamples(): readonly Example[] { // `if (result.ok)` can skip a line, so the test compares the count with the number of lines. const RESULT_LINE = /^(\s*)(.+);\s*\/\/ *('[^']*'|-?\d+(?:\.\d+)?|true|false|null|undefined)$/gm; +const SHOWN_VALUE = /;\s*\/\/ *('[^']*'|-?\d+(?:\.\d+)?|true|false|null|undefined)$/; +const TRAILING_COMMENT = /\S[ \t]+\/\/[ \t]*\S/; + +/** + * Lists the lines of a TypeScript example that end in a comment which the check cannot read as a + * result. Such a line would show a result that nothing compares. + * + * @param source - The code of the example. + * @returns The lines, in order. + */ +export function unreadableResults(source: string): readonly string[] { + return source + .split('\n') + .filter((line) => TRAILING_COMMENT.test(line) && !SHOWN_VALUE.test(line)); +} + /** * Counts the lines of a TypeScript example that show a result. * @@ -104,3 +158,29 @@ const FACTORY_LINE = ' new HttpFactory(),\n'; export function withMockGateway(source: string, baseUrl: string): string { return source.replace(FACTORY_LINE, `${FACTORY_LINE} baseUrl: '${baseUrl}',\n`); } + +/** + * Lists what stops the check from running a block. A block with the `notrun` mark has none. + * + * @param example - The block. + * @returns A sentence for each problem, or an empty list. + */ +export function problemsOf(example: Example): readonly string[] { + const { language, attributes, source, closed } = example; + // A PHP line that prints with no result comment needs no check here. The test compares the whole + // output of the example with the comments, so that line changes the output and fails it. + if (attributes === NOT_RUN) return []; + const problems: string[] = []; + if (!closed) problems.push('The fence has no closing fence.'); + if (attributes !== '') + problems.push(`The fence has the text "${attributes}" after the language.`); + if (!['ts', 'php', 'sh'].includes(language)) { + problems.push(`No check runs a block in the language "${language}".`); + } + if (language === 'ts') { + for (const line of unreadableResults(source)) { + problems.push(`The check cannot read the result in "${line.trim()}".`); + } + } + return problems; +} diff --git a/test/unit/examples.test.ts b/test/unit/examples.test.ts index 4108e20..c017979 100644 --- a/test/unit/examples.test.ts +++ b/test/unit/examples.test.ts @@ -12,8 +12,11 @@ import { countShownResults, examplesOf, pageExamples, + NOT_RUN, phpShownOutput, + problemsOf, toCheckedModule, + unreadableResults, withMockGateway, type Example, } from '../support/examples.ts'; @@ -24,10 +27,11 @@ const MODULES = { }; // The mock gateway runs in this process, so PHP must run without blocking it. const run = promisify(execFile); -const PHP_AUTOLOAD = join(process.cwd(), 'php/vendor/autoload.php'); +const ROOT = fileURLToPath(new URL('../../', import.meta.url)); +const PHP_AUTOLOAD = join(ROOT, 'php/vendor/autoload.php'); function readJson(path: string): unknown { - return JSON.parse(readFileSync(path, 'utf8')); + return JSON.parse(readFileSync(join(ROOT, path), 'utf8')); } // The packages that an install command on a page may name: the Gatepost packages, and the HTTP @@ -47,17 +51,109 @@ function named(examples: readonly Example[]): readonly (readonly [string, Exampl } function inLanguage(language: string): readonly (readonly [string, Example])[] { - return named(EXAMPLES.filter((example) => example.language === language)); + return named( + EXAMPLES.filter((example) => example.language === language && example.attributes !== NOT_RUN), + ); } describe('examplesOf', () => { it('finds each fenced block with its language', () => { const text = 'Intro.\n\n```ts\nconst a = 1;\n```\n\nMore.\n\n```sh\npnpm add x\n```\n'; expect(examplesOf('page.md', text)).toEqual([ - { page: 'page.md', index: 0, language: 'ts', source: 'const a = 1;\n' }, - { page: 'page.md', index: 1, language: 'sh', source: 'pnpm add x\n' }, + { + page: 'page.md', + index: 0, + language: 'ts', + attributes: '', + source: 'const a = 1;\n', + closed: true, + }, + { + page: 'page.md', + index: 1, + language: 'sh', + attributes: '', + source: 'pnpm add x\n', + closed: true, + }, ]); }); + + it('finds a fence with attributes, a tilde fence and a fence in a list', () => { + const text = [ + '```ts title="a.ts"', + 'a;', + '```', + '~~~php', + 'b;', + '~~~', + '- item', + '', + ' ```ts', + ' c;', + ' ```', + ].join('\n'); + const found = examplesOf('page.md', text); + expect(found.map(({ language, attributes, source }) => [language, attributes, source])).toEqual( + [ + ['ts', 'title="a.ts"', 'a;\n'], + ['php', '', 'b;\n'], + ['ts', '', 'c;\n'], + ], + ); + }); + + it('keeps a shorter fence of the other kind inside a block', () => { + const found = examplesOf('page.md', '````md\n```ts\nx;\n```\n````\n'); + expect(found).toHaveLength(1); + expect(found[0]?.source).toBe('```ts\nx;\n```\n'); + }); +}); + +describe('problemsOf', () => { + const block = (language: string, source: string, attributes = '', closed = true) => ({ + page: 'p.md', + index: 0, + language, + attributes, + source, + closed, + }); + + it('accepts a block that a check runs', () => { + expect(problemsOf(block('ts', 'a; // 1\n'))).toEqual([]); + expect(problemsOf(block('php', 'echo 1; // 1\n'))).toEqual([]); + expect(problemsOf(block('sh', 'pnpm add x\n'))).toEqual([]); + }); + + it('fails a block with text after the language', () => { + expect(problemsOf(block('ts', 'a; // 1\n', 'title="a.ts"'))).toHaveLength(1); + }); + + it('fails a block that the page never closes', () => { + expect(problemsOf(block('ts', 'a; // 1\n', '', false))).toHaveLength(1); + }); + + it('fails a block in a language that no check runs', () => { + expect(problemsOf(block('python', 'x\n'))).toHaveLength(1); + expect(problemsOf(block('', 'x\n'))).toHaveLength(1); + }); + + it.each(['a; // "x"', 'a; // [1, 2]', 'a; // {}', 'a // 1', 'a; // done'])( + 'fails the shown result that the check cannot read: %s', + (line) => { + expect(unreadableResults(`${line}\n`)).toEqual([line]); + expect(problemsOf(block('ts', `${line}\n`))).toHaveLength(1); + }, + ); + + it('does not fail a comment on its own line or a URL', () => { + expect(unreadableResults("// note\nconst u = 'http://x';\n")).toEqual([]); + }); + + it('lets the not-run mark skip every check', () => { + expect(problemsOf(block('python', 'x // "y"\n', NOT_RUN, false))).toEqual([]); + }); }); describe('toCheckedModule', () => { @@ -91,13 +187,31 @@ describe('withMockGateway', () => { }); describe('every code block on a page', () => { - it.each(named(EXAMPLES))('uses a language that a check runs: %s', (_, { language }) => { - expect(['ts', 'php', 'sh']).toContain(language); + it('exists, so the checks below cannot pass on no block', () => { + expect(EXAMPLES.length).toBeGreaterThan(0); + expect(EXAMPLES.some(({ language }) => language === 'ts')).toBe(true); + expect(EXAMPLES.some(({ language }) => language === 'php')).toBe(true); + expect(EXAMPLES.some(({ language }) => language === 'sh')).toBe(true); + }); + + it.each(named(EXAMPLES))('is one that a check runs, or carries the not-run mark: %s', (_, e) => { + expect(problemsOf(e)).toEqual([]); + }); + + it.each(inLanguage('ts'))('shows at least one result: %s', (_, { source }) => { + expect(countShownResults(source)).toBeGreaterThan(0); + }); + + it.each(inLanguage('php'))('shows at least one result: %s', (_, { source }) => { + expect(phpShownOutput(source).length).toBeGreaterThan(0); }); }); describe('every install command on a page', () => { it.each(inLanguage('sh'))('names a Gatepost package: %s', (_, { source }) => { + const lines = source.split('\n').filter((line) => line !== ''); + const other = lines.filter((line) => !/^(?:pnpm add|composer require) \S+$/.test(line)); + expect(other, 'lines that install nothing').toEqual([]); const pnpm = Array.from(source.matchAll(/^pnpm add (\S+)$/gm), ([, name]) => name); const composer = Array.from(source.matchAll(/^composer require (\S+)$/gm), ([, name]) => name); expect(pnpm.length + composer.length).toBeGreaterThan(0); @@ -116,17 +230,38 @@ describe('every example on a page', () => { beforeAll(async () => { mock = await startMockServer({ port: 0 }); process.env['GATEPOST_MOCK_URL'] = mock.url; - mkdirSync('temp', { recursive: true }); - folder = mkdtempSync(join(process.cwd(), 'temp', 'examples-')); + mkdirSync(join(ROOT, 'temp'), { recursive: true }); + folder = mkdtempSync(join(ROOT, 'temp', 'examples-')); }); + const moduleOf = (name: string): string => join(folder, `${name.replace(/\W+/g, '-')}.ts`); + afterAll(async () => { await mock.close(); rmSync(folder, { recursive: true, force: true }); }); + // Vite removes types without checking them, so a wrong type in an example needs its own run. + it('type-checks every TypeScript example', { timeout: 120_000 }, async () => { + for (const [name, { source }] of inLanguage('ts')) { + writeFileSync(moduleOf(name), toCheckedModule(source, MODULES)); + } + writeFileSync( + join(folder, 'tsconfig.json'), + JSON.stringify({ extends: join(ROOT, 'tsconfig.json'), include: ['*.ts'] }), + ); + const tsc = join(ROOT, 'node_modules/typescript/bin/tsc'); + let problems = ''; + try { + await run(process.execPath, [tsc, '--noEmit', '-p', folder]); + } catch (error) { + problems = error instanceof Error && 'stdout' in error ? String(error.stdout) : String(error); + } + expect(problems).toBe(''); + }); + it.each(inLanguage('ts'))('runs and shows only true results: %s', async (name, { source }) => { - const file = join(folder, `${name.replace(/\W+/g, '-')}.ts`); + const file = moduleOf(name); writeFileSync(file, toCheckedModule(source, MODULES)); const { checked } = (await import(pathToFileURL(file).href)) as { checked: number }; expect(checked, 'the number of results that the example compared').toBe( From 24f31c4500c5cd38f1135fd2fe4c5e9d10cb26af Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:05:50 +0100 Subject: [PATCH 15/27] fix: fail the PHP reference and examples when they cannot be trusted An empty or missing package gave an empty PHP reference with exit 0. The script now stops with a message. The timeout sentence of the guide said the client waits, but the client only reads a failed attempt, so the sentence now says that the HTTP client owns the limit. A PHP example that missed the expected factory line kept the default base URL and could call the real gateway. The helper now throws unless the example calls only loopback addresses. A PHP line with a result comment that no output check compares now fails too. The reference script also keeps typed parameters with spaces, hides internal members, reads nested folders, and the build names a missing PHP. Signed-off-by: MobileMage --- astro.config.mjs | 20 +++++++++--- scripts/php-reference.php | 48 +++++++++++++++++++++++----- src/content/docs/guides/php.md | 4 ++- test/support/examples.ts | 55 +++++++++++++++++++++++++++++++-- test/unit/examples.test.ts | 32 ++++++++++++++++++- test/unit/php-reference.test.ts | 22 ++++++++++++- 6 files changed, 163 insertions(+), 18 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 30a7599..b2ff980 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -42,11 +42,21 @@ export default defineConfig({ 'astro:config:setup': async () => { writeSpecPages(import.meta.dirname); await writeClientSamples(import.meta.dirname); - execFileSync( - 'php', - ['scripts/php-reference.php', 'php', 'src/content/docs/reference/php'], - { cwd: import.meta.dirname }, - ); + try { + execFileSync( + 'php', + ['scripts/php-reference.php', 'php', 'src/content/docs/reference/php'], + { cwd: import.meta.dirname, stdio: ['ignore', 'inherit', 'inherit'] }, + ); + } catch (error) { + // The reference script prints its own message. A missing PHP gives only ENOENT. + if (error.code === 'ENOENT') { + throw new Error('PHP 8.1 or later is needed to build the PHP reference.', { + cause: error, + }); + } + throw error; + } }, }, }, diff --git a/scripts/php-reference.php b/scripts/php-reference.php index 4d45c9b..340c18a 100644 --- a/scripts/php-reference.php +++ b/scripts/php-reference.php @@ -12,6 +12,9 @@ namespace Gatepost\Docs; +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use ReflectionClass; use ReflectionClassConstant; use ReflectionEnum; @@ -22,8 +25,11 @@ use ReflectionProperty; use ReflectionType; use RuntimeException; +use SplFileInfo; const PREFIX = 'Gatepost\\Postcode\\'; +// A @param tag: a type that can hold spaces inside <>, {} or (), the name and the meaning. +const PARAM_TAG = '/^(?:[^\s<{(]|<[^>]*>|\{[^}]*\}|\([^)]*\))+\s+\$(\w+)\s*(.*)$/'; /** * @return array{string, list} The description, and each tag with its text. @@ -134,18 +140,22 @@ function methodLines(ReflectionMethod $method): array function tagLines(array $tags, array $parameters): array { $names = \array_map(static fn(ReflectionParameter $p): string => $p->getName(), $parameters); - $rows = []; + $meanings = []; $throws = []; foreach ($tags as [$tag, $text]) { $text = \trim((string) \preg_replace('/\s+/', ' ', $text)); - if ($tag === 'param' && \preg_match('/^\S+\s+\$(\w+)\s*(.*)$/', $text, $param) === 1) { + if ($tag === 'param' && \preg_match(PARAM_TAG, $text, $param) === 1) { if (\in_array($param[1], $names, true)) { - $rows[] = "| `\${$param[1]}` | {$param[2]} |"; + $meanings[$param[1]] = $param[2]; } } elseif ($tag === 'throws') { $throws[] = "- {$text}"; } } + $rows = []; + foreach ($names as $name) { + $rows[] = "| `\${$name}` | " . ($meanings[$name] ?? 'Not documented.') . ' |'; + } $lines = $rows === [] ? [] : ['#### Parameters', '', '| Name | Meaning |', '| --- | --- |']; $lines = $rows === [] ? [] : [...$lines, ...$rows, '']; @@ -195,7 +205,7 @@ function classLines(ReflectionClass $class): array } } foreach ($class->getReflectionConstants(ReflectionClassConstant::IS_PUBLIC) as $constant) { - if (!$constant->isEnumCase()) { + if (!$constant->isEnumCase() && !isInternal($constant->getDocComment())) { $code = $constant->getName() . ' = ' . literal($constant->getValue()); $members = [...$members, ...memberLines($constant, $code)]; } @@ -203,6 +213,9 @@ function classLines(ReflectionClass $class): array // An enum has the properties name and value, which the cases already show. $properties = $class->isEnum() ? [] : $class->getProperties(ReflectionProperty::IS_PUBLIC); foreach ($properties as $property) { + if (isInternal($property->getDocComment())) { + continue; + } $code = typeName($property->getType()) . ' $' . $property->getName(); $members = [...$members, ...memberLines($property, $code)]; } @@ -238,8 +251,17 @@ function publicClasses(string $source): array } }); $classes = []; - $files = \glob($source . '/{,*/}*.php', \GLOB_BRACE); - foreach ($files === false ? [] : $files as $file) { + $files = []; + $found = new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $source, + FilesystemIterator::SKIP_DOTS, + )); + foreach ($found as $item) { + if ($item instanceof SplFileInfo && $item->getExtension() === 'php') { + $files[] = $item->getPathname(); + } + } + foreach ($files as $file) { $path = \substr($file, \strlen($source) + 1, -4); $name = PREFIX . \str_replace('/', '\\', $path); $exists = \class_exists($name) || \interface_exists($name) || \enum_exists($name); @@ -272,7 +294,17 @@ function writeReference(array $arguments): int return 2; } [$package, $output] = $arguments; + if (!\is_dir($package . '/src')) { + \fwrite(\STDERR, "The package folder {$package} has no src folder.\n"); + + return 1; + } $classes = publicClasses($package . '/src'); + if ($classes === []) { + \fwrite(\STDERR, "The folder {$package}/src holds no public class. Write no page.\n"); + + return 1; + } if (!\is_dir($output) && !\mkdir($output, 0o777, true)) { throw new RuntimeException("Cannot make {$output}."); } @@ -284,7 +316,9 @@ function writeReference(array $arguments): int '---', '', 'The doc comments of the PHP package give this reference. Each page lists the public', - 'members of one class or enum.', + 'members of one class or enum. A name with a backslash, such as', + '`Psr\Http\Client\ClientInterface`, is a full name. A name without one is in the', + '`Gatepost\Postcode` namespace.', '', ]; foreach ($classes as $class) { diff --git a/src/content/docs/guides/php.md b/src/content/docs/guides/php.md index e7f6831..0e56e1b 100644 --- a/src/content/docs/guides/php.md +++ b/src/content/docs/guides/php.md @@ -52,7 +52,9 @@ The client needs a PSR-18 HTTP client and a PSR-17 request factory, such as Guzz composer require guzzlehttp/guzzle ``` -Keep the key on a server, and read it from the environment. Set the timeout of your HTTP client, and pass the same value as `timeoutMs`. PSR-18 has no timeout of its own, so the client relies on the timeout of the HTTP client. One `timeoutMs` applies to every call. When you pass none, the client waits 8000 ms, and 15000 ms for `autocomplete`. +Keep the key on a server, and read it from the environment. PSR-18 has no timeout, so the client cannot stop a slow request. Set the timeout of your HTTP client. With no timeout on the HTTP client, a request can wait without limit. + +Pass the same value as `timeoutMs`. It does not set a limit. It tells the client how to read a failed attempt. An attempt that fails after `timeoutMs` or more gives `timeout`, and a faster failure gives `network_error`. One `timeoutMs` applies to every call. When you pass none, the client uses 8000 ms, and 15000 ms for `autocomplete`. ```php line ?? ''); } +/** + * Lists the lines of a PHP example that end in a comment which the check cannot compare. Only an + * `echo` line with a comment shows a result, because the test compares the output with it. + * + * @param source - The code of the example. + * @returns The lines, in order. + */ +export function unreadablePhpResults(source: string): readonly string[] { + return source + .split('\n') + .filter((line) => TRAILING_COMMENT.test(line) && !/^\s*echo .+; \/\/ .+$/.test(line)); +} + const FACTORY_LINE = ' new HttpFactory(),\n'; +const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; + +function isLoopback(address: string): boolean { + try { + return LOOPBACK_HOSTS.includes(new URL(address).hostname); + } catch { + return false; + } +} /** * Points the client of a PHP example at the mock gateway. The page shows the client with - * NIPOST's address, so the test adds one named argument after the request factory. + * NIPOST's address, so the test adds one named argument after the request factory. A call that + * reaches NIPOST would be a breach of the rules, so this function throws unless the example is + * sure to call only a loopback address. * * @param source - The code of the example. - * @param baseUrl - The address of the mock gateway. + * @param baseUrl - The address of the mock gateway. It must be on this machine. * @returns The code that the test runs. */ export function withMockGateway(source: string, baseUrl: string): string { - return source.replace(FACTORY_LINE, `${FACTORY_LINE} baseUrl: '${baseUrl}',\n`); + if (!isLoopback(baseUrl)) { + throw new Error(`The mock gateway address ${baseUrl} is not a loopback address.`); + } + let pointed = source; + if (source.includes('new PostcodeClient(')) { + if (/\bbaseUrl\s*:/.test(source)) { + throw new Error('The example names a base URL of its own. The test sets the base URL.'); + } + if (!source.includes(FACTORY_LINE)) { + throw new Error( + `The test cannot point the example at the mock gateway: no line "${FACTORY_LINE.trim()}".`, + ); + } + pointed = source.replace(FACTORY_LINE, `${FACTORY_LINE} baseUrl: '${baseUrl}',\n`); + } + for (const [address] of pointed.matchAll(/https?:\/\/[^\s'"`)]+/g)) { + if (!isLoopback(address)) { + throw new Error(`The example holds the address ${address}, which is not a loopback address.`); + } + } + return pointed; } /** @@ -182,5 +226,10 @@ export function problemsOf(example: Example): readonly string[] { problems.push(`The check cannot read the result in "${line.trim()}".`); } } + if (language === 'php') { + for (const line of unreadablePhpResults(source)) { + problems.push(`The check cannot compare the result in "${line.trim()}" with the output.`); + } + } return problems; } diff --git a/test/unit/examples.test.ts b/test/unit/examples.test.ts index c017979..a555dc0 100644 --- a/test/unit/examples.test.ts +++ b/test/unit/examples.test.ts @@ -126,6 +126,12 @@ describe('problemsOf', () => { expect(problemsOf(block('sh', 'pnpm add x\n'))).toEqual([]); }); + it('fails a PHP line that shows a result with no output to compare', () => { + expect(problemsOf(block('php', '$r->valid; // true\n'))).toHaveLength(1); + expect(problemsOf(block('php', 'var_dump($r->valid); // bool(true)\n'))).toHaveLength(1); + expect(problemsOf(block('php', 'echo $r->valid; // 1\n$a = 1; // note\n'))).toHaveLength(1); + }); + it('fails a block with text after the language', () => { expect(problemsOf(block('ts', 'a; // 1\n', 'title="a.ts"'))).toHaveLength(1); }); @@ -181,9 +187,32 @@ describe('withMockGateway', () => { ); }); - it('leaves a source without that line as it is', () => { + it('leaves a source with no client as it is', () => { expect(withMockGateway('echo 1;', 'http://127.0.0.1:1')).toBe('echo 1;'); }); + + it('fails when the request factory is not on its usual line', () => { + const source = 'new PostcodeClient(\n $http,\n new HttpFactory(),\n);'; + expect(() => withMockGateway(source, 'http://127.0.0.1:1')).toThrow(/cannot point/); + }); + + it('fails when the example names a base URL of its own', () => { + const source = + "new PostcodeClient(\n $http,\n new HttpFactory(),\n baseUrl: 'https://api.postcode.gov.ng',\n);"; + expect(() => withMockGateway(source, 'http://127.0.0.1:1')).toThrow(/base URL/); + }); + + it('fails when the example holds an address of another host', () => { + const source = "$x = 'https://api.postcode.gov.ng/v1/lookup';\n"; + expect(() => withMockGateway(source, 'http://127.0.0.1:1')).toThrow(/not a loopback/); + }); + + it.each(['https://api.postcode.gov.ng', 'http://example.com:80', 'not a url'])( + 'refuses the mock address %s, which is not on this machine', + (address) => { + expect(() => withMockGateway('echo 1;', address)).toThrow(/loopback/); + }, + ); }); describe('every code block on a page', () => { @@ -276,6 +305,7 @@ describe('every example on a page', () => { writeFileSync(file, withMockGateway(source, mock.url)); const { stdout } = await run('php', ['-d', `auto_prepend_file=${PHP_AUTOLOAD}`, file], { env: { ...process.env, NIPOST_API_KEY: 'nipost_test_mock_l3' }, + timeout: 60_000, }); expect(stdout.split('\n').slice(0, -1)).toEqual(phpShownOutput(source)); }, diff --git a/test/unit/php-reference.test.ts b/test/unit/php-reference.test.ts index 6167b7a..90f957f 100644 --- a/test/unit/php-reference.test.ts +++ b/test/unit/php-reference.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 import { execFile } from 'node:child_process'; -import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -54,6 +54,26 @@ describe('the PHP reference', () => { expect(page('client-errorcode')).toContain("- `RateLimited = 'rate_limited'`"); }); + it('fails with a message when the package folder is missing', async () => { + await expect(run('php', [SCRIPT, join(folder, 'absent'), folder])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining('has no src folder') as unknown, + }); + }); + + it('fails with a message when the package holds no public class', async () => { + const empty = mkdtempSync(join(tmpdir(), 'php-empty-')); + mkdirSync(join(empty, 'src')); + try { + await expect(run('php', [SCRIPT, empty, folder])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining('holds no public class') as unknown, + }); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }); + it('refuses a call without two folders', async () => { await expect(run('php', [SCRIPT])).rejects.toMatchObject({ code: 2 }); }); From 2a25ae25052a99abe41da830de28b81e6322a295 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:10:27 +0100 Subject: [PATCH 16/27] feat: add the readiness tracker and check every link The press and the upstream projects look for the readiness tracker next to the tools, so the docs site now holds it. Every build checks each link inside the site, with its fragment, and a weekly job checks the links to other sites. No check ever calls NIPOST's gateway or platform. Signed-off-by: MobileMage --- .github/workflows/links.yml | 36 ++++++++ README.md | 12 ++- astro.config.mjs | 2 +- package.json | 7 +- pnpm-lock.yaml | 158 ++++++++++++++++++++++++++++++++++ scripts/check-links.ts | 44 ++++++++++ src/content/docs/readiness.md | 22 +++++ test/unit/check-links.test.ts | 29 +++++++ 8 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/links.yml create mode 100644 scripts/check-links.ts create mode 100644 src/content/docs/readiness.md create mode 100644 test/unit/check-links.test.ts diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000..98e7e47 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 The Gatepost authors +# SPDX-License-Identifier: Apache-2.0 +name: links + +# The links to other sites change without a commit here, so a weekly job checks them. A pull +# request checks only the links inside the site, which need no network. +on: + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + external: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.4' + tools: composer + coverage: none + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm sdk + - run: pnpm build + - run: pnpm links:external diff --git a/README.md b/README.md index 0acdda8..0564ffd 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ The docs site of Gatepost: guides for each SDK, the API reference, the spec and git clone --recurse-submodules https://github.com/gatepost-dev/docs cd docs pnpm install +pnpm sdk ``` +`pnpm sdk` installs and builds the SDKs in the `js` and `php` submodules, because the site documents their source and runs its examples against them. + ## Quickstart ```sh @@ -31,7 +34,11 @@ Astro serves the site at `http://localhost:4321/docs/`. The scripts turn off Ast ## What it does - Builds a static site with Astro and Starlight, for GitHub Pages. -- Fails when a page loads anything from another website, or when a page lacks the line that says that NIPOST did not make the site. +- Writes the API reference from the TSDoc comments and the PHP doc comments of the submodules. +- Shows the grammar, the client contract and the glossary of the `spec` submodule. +- Runs each code example of each page against the SDKs and Gatepost's mock gateway. +- Checks each page with axe for WCAG 2.2 AA, in the light and the dark theme. +- Fails when a page loads anything from another website, or when a link inside the site breaks. ## Requirements @@ -39,10 +46,11 @@ Astro serves the site at `http://localhost:4321/docs/`. The scripts turn off Ast | ----------------------------------- | ----------------------------------- | | Node | 24.15 or later | | pnpm | 12, through Corepack | +| PHP, for the PHP pages | 8.1 or later, with Composer | | Python, for `check-tells` and REUSE | 3.11 or later, with uv | | Gatepost spec | the version in the `spec` submodule | -`pnpm check` runs every check that CI runs. +`pnpm check` runs every check that CI runs. The browser tests need Chromium: run `pnpm exec playwright install chromium` once. ## Docs diff --git a/astro.config.mjs b/astro.config.mjs index b2ff980..31158e0 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -107,7 +107,7 @@ export default defineConfig({ }, ], }, - { label: 'Project', items: ['privacy'] }, + { label: 'Project', items: ['readiness', 'privacy'] }, ], }), ], diff --git a/package.json b/package.json index b240a48..a754834 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", "check": "run-s check:source check:site check:policy", "check:source": "run-s format:check lint typecheck php:check test", - "check:site": "run-s build test:site test:browser", + "check:site": "run-s build test:site links test:browser", "check:policy": "run-s tells prose context:check licences reuse:check workflows:check", "format": "prettier --write .", "format:check": "prettier --check .", @@ -26,6 +26,8 @@ "php:check": "php php/vendor/bin/phpstan analyse --no-progress --level max scripts", "test": "vitest run --project unit --coverage", "test:site": "vitest run --project site", + "links": "node scripts/check-links.ts", + "links:external": "node scripts/check-links.ts --external", "tells": "python3 spec/scripts/check-tells", "prose": "node scripts/check-prose.ts", "context:check": "cmp CONTEXT.md spec/CONTEXT.md", @@ -54,13 +56,14 @@ "@gatepost/mock-server": "link:js/packages/mock-server", "@playwright/test": "^1.63.0", "@types/node": "^24.19.1", + "@vitest/coverage-v8": "^5.0.3", "eslint": "^10.11.0", + "linkinator": "^8.1.0", "npm-run-all2": "^9.0.3", "prettier": "^3.9.9", "prettier-plugin-astro": "^1.1.0", "typescript": "6.0.3", "typescript-eslint": "^8.71.0", - "@vitest/coverage-v8": "^5.0.3", "vitest": "^5.0.3", "yaml": "^2.9.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b3a05f..a54a608 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: eslint: specifier: ^10.11.0 version: 10.11.0(jiti@2.6.1) + linkinator: + specifier: ^8.1.0 + version: 8.1.0 npm-run-all2: specifier: ^9.0.3 version: 9.0.3 @@ -1618,6 +1621,10 @@ packages: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chalk@6.0.1: + resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} + engines: {node: '>=22'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1778,16 +1785,32 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -1802,6 +1825,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1824,6 +1851,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1968,6 +1998,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + global-directory@5.0.0: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} @@ -2036,6 +2070,10 @@ packages: html-whitespace-sensitive-tag-names@3.0.1: resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -2240,6 +2278,11 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + linkinator@8.1.0: + resolution: {integrity: sha512-3AeucoHrnlsbxPinqP1w0bsVvit2ESRSFEhFoHAsJf8CNjvmNGny/f/AgtYQXVmz+x9qn+a74DlPX06yYbSRXg==} + engines: {node: '>=22'} + hasBin: true + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2264,6 +2307,16 @@ packages: resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==} hasBin: true + marked-gfm-heading-id@4.1.4: + resolution: {integrity: sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==} + peerDependencies: + marked: '>=13 <19' + + marked@18.0.14: + resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==} + engines: {node: '>= 20'} + hasBin: true + mdast-util-directive@3.1.0: resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} @@ -2295,6 +2348,10 @@ packages: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + micromark-core-commonmark@2.0.4: resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} @@ -2364,10 +2421,19 @@ packages: micromark@4.0.3: resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2488,6 +2554,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -2642,6 +2712,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2691,6 +2765,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + srcset@5.0.3: + resolution: {integrity: sha512-AZswtOXIsu0LeHdo6YY7d0r2pCH2Rl1D8ae1utvXUX4GxG3RggsVUAOFX1r8RI4YHFMYb4g89+UBPBv3mNUU2g==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + starlight-typedoc@0.23.1: resolution: {integrity: sha512-H45Mxv4mZ1xUMhUNjTR4CwKDgKKHZboQA5v5+vTVMdjlCX02Xo7veTB7+F/ivmMHPmQ7SV5auF2BTlU/EpQIoQ==} engines: {node: '>=22.12.0'} @@ -3147,6 +3225,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -4555,6 +4636,8 @@ snapshots: chai@6.3.0: {} + chalk@6.0.1: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -4692,18 +4775,36 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.1.0 + domelementtype@2.3.0: {} + domelementtype@3.0.0: {} + domhandler@5.0.3: dependencies: domelementtype: 2.3.0 + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 domelementtype: 2.3.0 domhandler: 5.0.3 + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dset@3.1.4: {} emmet@2.4.11: @@ -4715,6 +4816,8 @@ snapshots: entities@4.5.0: {} + entities@8.1.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -4756,6 +4859,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-scope@9.1.2: @@ -4909,6 +5014,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + global-directory@5.0.0: dependencies: ini: 6.0.0 @@ -5041,6 +5152,13 @@ snapshots: html-whitespace-sensitive-tag-names@3.0.1: {} + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.1.0 + http-cache-semantics@4.2.0: {} i18next@26.4.2(typescript@6.0.3): @@ -5181,6 +5299,20 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkinator@8.1.0: + dependencies: + chalk: 6.0.1 + escape-html: 1.0.3 + glob: 13.0.6 + htmlparser2: 12.0.0 + marked: 18.0.14 + marked-gfm-heading-id: 4.1.4(marked@18.0.14) + meow: 14.1.0 + mime: 4.1.0 + saxes: 6.0.0 + srcset: 5.0.3 + undici: 8.11.2 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5210,6 +5342,13 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + marked-gfm-heading-id@4.1.4(marked@18.0.14): + dependencies: + github-slugger: 2.0.0 + marked: 18.0.14 + + marked@18.0.14: {} + mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 @@ -5284,6 +5423,8 @@ snapshots: memorystream@0.3.1: {} + meow@14.1.0: {} + micromark-core-commonmark@2.0.4: dependencies: decode-named-character-reference: 1.3.0 @@ -5433,10 +5574,14 @@ snapshots: transitivePeerDependencies: - supports-color + mime@4.1.0: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.12 + minipass@7.1.3: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -5563,6 +5708,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -5721,6 +5871,10 @@ snapshots: sax@1.6.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} sharp@0.35.5(@types/node@24.19.1): @@ -5791,6 +5945,8 @@ snapshots: space-separated-tokens@2.0.2: {} + srcset@5.0.3: {} + starlight-typedoc@0.23.1(@astrojs/starlight@0.42.5(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typescript@6.0.3))(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typedoc-plugin-markdown@4.13.1(typedoc@0.28.20(typescript@6.0.3)))(typedoc@0.28.20(typescript@6.0.3)): dependencies: '@astrojs/starlight': 0.42.5(astro@7.3.5(@types/node@24.19.1)(jiti@2.6.1)(yaml@2.9.1))(typescript@6.0.3) @@ -6149,6 +6305,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + xmlchars@2.2.0: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {} diff --git a/scripts/check-links.ts b/scripts/check-links.ts new file mode 100644 index 0000000..ff631c3 --- /dev/null +++ b/scripts/check-links.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// Checks the links of the built site. By default it follows only the links inside the site, +// with their fragments, so it needs no network. With --external, it also checks the links to +// other sites, as a weekly job does. It never calls the hosts that NIPOST's rules close to us. +import process from 'node:process'; +import { check, LinkState } from 'linkinator'; + +// linkinator serves dist/ itself, on a free port of this address. +const LOCAL = 'http://127\\.0\\.0\\.1:\\d+'; + +// The gateway and NIPOST's platform: Gatepost calls neither from a link checker. +const NEVER = [ + '^https?://api\\.postcode\\.gov\\.ng', + '^https?://platform\\.postcode\\.gov\\.ng', + '^https?://(?:www\\.)?postcode\\.gov\\.ng/api/', +]; + +/** + * Lists the links that the check skips. + * + * @param external - Whether the check follows links to other sites. + * @returns The patterns of the links to skip. + */ +export function linksToSkip(external: boolean): readonly string[] { + return external ? NEVER : [...NEVER, `^(?!${LOCAL})`]; +} + +if (import.meta.main) { + const result = await check({ + path: 'dist', + recurse: true, + checkFragments: true, + linksToSkip: [...linksToSkip(process.argv.includes('--external'))], + // The site lives under /docs on GitHub Pages, and the check serves dist/ at the root. + urlRewriteExpressions: [{ pattern: new RegExp(`^(${LOCAL})/docs/`), replacement: '$1/' }], + }); + const broken = result.links.filter((link) => link.state === LinkState.BROKEN); + for (const link of broken) { + process.stdout.write(`${link.parent ?? ''}: ${link.url} (${String(link.status)})\n`); + } + process.stdout.write(`${String(result.links.length)} links, ${String(broken.length)} broken.\n`); + process.exitCode = broken.length === 0 ? 0 : 1; +} diff --git a/src/content/docs/readiness.md b/src/content/docs/readiness.md new file mode 100644 index 0000000..730075a --- /dev/null +++ b/src/content/docs/readiness.md @@ -0,0 +1,22 @@ +--- +title: Readiness tracker +description: Common tools that reject or hide Nigeria's new postcode, and the fix that Gatepost sent to each one. +--- + +On 1 October 2026, NIPOST launched a postcode of 11 letters and digits. Some common tools still accept only the old 6-digit postcode, or hide the postcode field for Nigeria. Gatepost sends a fix to each tool, and this page tracks each fix. + +
    + +| Tool | What fails | Fix | Status | +| ------------------------------------------------------------------------- | ------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | +| [postcode-validator](https://github.com/melwynfurtado/postcode-validator) | The rule for NG accepts only 6 digits. | pull request | [sent, open](https://github.com/melwynfurtado/postcode-validator/pull/302) | +| [faker-js](https://github.com/faker-js/faker) | The `en_NG` locale makes 4-digit and 5-digit postcodes. | pull request | [sent, open](https://github.com/faker-js/faker/pull/4124) | +| [Shopify worldwide](https://github.com/Shopify/worldwide) | The rule for NG accepts only 6 digits. | pull request | [sent, open](https://github.com/Shopify/worldwide/pull/622) | +| [WooCommerce](https://github.com/woocommerce/woocommerce) | The checkout hides the postcode field for NG. | pull request | not sent | +| [Google libaddressinput](https://github.com/google/libaddressinput) | The rule for NG accepts only 6 digits. | issue | not sent | + +
    + +Each fix accepts both the old 6-digit postcode and the new 11-character postcode. Each fix also rejects `00` in the LGA and the unit, as NIPOST's format does. + +When a status changes, the row links the pull request or the issue. diff --git a/test/unit/check-links.test.ts b/test/unit/check-links.test.ts new file mode 100644 index 0000000..64680de --- /dev/null +++ b/test/unit/check-links.test.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { linksToSkip } from '../../scripts/check-links.ts'; + +function skipped(url: string, external: boolean): boolean { + return linksToSkip(external).some((pattern) => new RegExp(pattern).test(url)); +} + +describe('linksToSkip', () => { + it('follows only the links inside the site by default', () => { + expect(skipped('http://127.0.0.1:50123/guides/typescript/', false)).toBe(false); + expect(skipped('https://docs.postcode.gov.ng/', false)).toBe(true); + }); + + it('follows the links to other sites with --external', () => { + expect(skipped('https://docs.postcode.gov.ng/', true)).toBe(false); + }); + + it("never calls NIPOST's gateway, its platform or the API paths of its website", () => { + for (const url of [ + 'https://api.postcode.gov.ng/v1/lookup', + 'https://platform.postcode.gov.ng/', + 'https://postcode.gov.ng/api/anything', + ]) { + expect(skipped(url, true), url).toBe(true); + } + }); +}); From 04daf9979b54cb686bf05dc27d748eea3f7fe222 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:13:01 +0100 Subject: [PATCH 17/27] fix: make the PHP example helper fail closed on any client form The helper edited an example only when it held the short form new PostcodeClient(. An example with the full class name skipped the edit and the check, and could then call the real gateway. The helper now acts on any mention of the client class, and it throws, with the name of the example, unless the result names the loopback gateway. The PHP child process had a 60 s limit under a 5 s test limit, so the child could outlive a failed test. The test limit is now above the child limit, so the child stops first. Signed-off-by: MobileMage --- test/support/examples.ts | 19 ++++++++++++++----- test/unit/examples.test.ts | 22 ++++++++++++++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/test/support/examples.ts b/test/support/examples.ts index 7118441..b58d6d1 100644 --- a/test/support/examples.ts +++ b/test/support/examples.ts @@ -177,29 +177,38 @@ function isLoopback(address: string): boolean { * * @param source - The code of the example. * @param baseUrl - The address of the mock gateway. It must be on this machine. + * @param name - The name of the example, for the message of a failure. * @returns The code that the test runs. */ -export function withMockGateway(source: string, baseUrl: string): string { +export function withMockGateway(source: string, baseUrl: string, name = 'the example'): string { if (!isLoopback(baseUrl)) { throw new Error(`The mock gateway address ${baseUrl} is not a loopback address.`); } let pointed = source; - if (source.includes('new PostcodeClient(')) { + // Any mention of the client class counts, also its full name, so that no form skips the edit. + if (source.includes('PostcodeClient')) { if (/\bbaseUrl\s*:/.test(source)) { - throw new Error('The example names a base URL of its own. The test sets the base URL.'); + throw new Error(`${name} names a base URL of its own. The test sets the base URL.`); } if (!source.includes(FACTORY_LINE)) { throw new Error( - `The test cannot point the example at the mock gateway: no line "${FACTORY_LINE.trim()}".`, + `The test cannot point ${name} at the mock gateway: no line "${FACTORY_LINE.trim()}".`, ); } pointed = source.replace(FACTORY_LINE, `${FACTORY_LINE} baseUrl: '${baseUrl}',\n`); } for (const [address] of pointed.matchAll(/https?:\/\/[^\s'"`)]+/g)) { if (!isLoopback(address)) { - throw new Error(`The example holds the address ${address}, which is not a loopback address.`); + throw new Error(`${name} holds the address ${address}, which is not a loopback address.`); } } + // Fail closed: a client that the edit did not reach would call the real gateway. + if ( + /PostcodeClient|Gatepost\\Postcode\\Client/.test(pointed) && + !pointed.includes(`baseUrl: '${baseUrl}'`) + ) { + throw new Error(`${name} does not point at the mock gateway.`); + } return pointed; } diff --git a/test/unit/examples.test.ts b/test/unit/examples.test.ts index a555dc0..0bcb8aa 100644 --- a/test/unit/examples.test.ts +++ b/test/unit/examples.test.ts @@ -28,6 +28,7 @@ const MODULES = { // The mock gateway runs in this process, so PHP must run without blocking it. const run = promisify(execFile); const ROOT = fileURLToPath(new URL('../../', import.meta.url)); +const PHP_CHILD_LIMIT_MS = 60_000; const PHP_AUTOLOAD = join(ROOT, 'php/vendor/autoload.php'); function readJson(path: string): unknown { @@ -196,6 +197,21 @@ describe('withMockGateway', () => { expect(() => withMockGateway(source, 'http://127.0.0.1:1')).toThrow(/cannot point/); }); + it('points an example that writes the full class name at the mock gateway', () => { + const source = + 'new \\Gatepost\\Postcode\\Client\\PostcodeClient(\n $http,\n new HttpFactory(),\n);'; + expect(withMockGateway(source, 'http://127.0.0.1:1')).toContain( + "baseUrl: 'http://127.0.0.1:1'", + ); + }); + + it('fails, and names the example, when a full class name has no usual factory line', () => { + const source = 'new \\Gatepost\\Postcode\\Client\\PostcodeClient($http, $factory);'; + expect(() => withMockGateway(source, 'http://127.0.0.1:1', 'php.md block 9')).toThrow( + /php\.md block 9/, + ); + }); + it('fails when the example names a base URL of its own', () => { const source = "new PostcodeClient(\n $http,\n new HttpFactory(),\n baseUrl: 'https://api.postcode.gov.ng',\n);"; @@ -302,12 +318,14 @@ describe('every example on a page', () => { 'runs and prints only the lines it shows: %s', async (name, { source }) => { const file = join(folder, `${name.replace(/\W+/g, '-')}.php`); - writeFileSync(file, withMockGateway(source, mock.url)); + writeFileSync(file, withMockGateway(source, mock.url, name)); const { stdout } = await run('php', ['-d', `auto_prepend_file=${PHP_AUTOLOAD}`, file], { env: { ...process.env, NIPOST_API_KEY: 'nipost_test_mock_l3' }, - timeout: 60_000, + timeout: PHP_CHILD_LIMIT_MS, }); expect(stdout.split('\n').slice(0, -1)).toEqual(phpShownOutput(source)); }, + // The test limit is above the limit of the child, so the child stops first. + PHP_CHILD_LIMIT_MS + 30_000, ); }); From cccdaac9857daff21f8ccd0e03d6f6594e28407c Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:16:33 +0100 Subject: [PATCH 18/27] fix: check the links as GitHub Pages serves them, under /docs/ The link check served the build at its root and rewrote /docs/ away, so a link without the /docs base passed here and gave a 404 on GitHub Pages. The check now serves the build under /docs/ and reports such a link as broken. It also skips the /api path of NIPOST's website in every form, retries after a 429 answer and fails when the crawl finds too few links. The readiness page now says which fixes are sent and which are not, gives its date and links NIPOST's docs. Signed-off-by: MobileMage --- README.md | 2 +- scripts/check-links.ts | 69 +++++++++++++++++++++++++++-------- src/content/docs/readiness.md | 8 ++-- test/unit/check-links.test.ts | 59 +++++++++++++++++++++++++++++- 4 files changed, 115 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0564ffd..3afbdf1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Astro serves the site at `http://localhost:4321/docs/`. The scripts turn off Ast - Shows the grammar, the client contract and the glossary of the `spec` submodule. - Runs each code example of each page against the SDKs and Gatepost's mock gateway. - Checks each page with axe for WCAG 2.2 AA, in the light and the dark theme. -- Fails when a page loads anything from another website, or when a link inside the site breaks. +- Fails when a page loads anything from another website, when a page lacks the line that says that NIPOST did not make the site, or when a link inside the site breaks. The link check serves the site under `/docs/`, as GitHub Pages does. ## Requirements diff --git a/scripts/check-links.ts b/scripts/check-links.ts index ff631c3..288abbd 100644 --- a/scripts/check-links.ts +++ b/scripts/check-links.ts @@ -3,19 +3,29 @@ // Checks the links of the built site. By default it follows only the links inside the site, // with their fragments, so it needs no network. With --external, it also checks the links to // other sites, as a weekly job does. It never calls the hosts that NIPOST's rules close to us. +import { cpSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import process from 'node:process'; import { check, LinkState } from 'linkinator'; -// linkinator serves dist/ itself, on a free port of this address. +// linkinator serves a folder itself, on a free port of this address. const LOCAL = 'http://127\\.0\\.0\\.1:\\d+'; +// A crawl that finds fewer links than this has missed the site. +const MINIMUM_LINKS = 100; + // The gateway and NIPOST's platform: Gatepost calls neither from a link checker. const NEVER = [ '^https?://api\\.postcode\\.gov\\.ng', '^https?://platform\\.postcode\\.gov\\.ng', - '^https?://(?:www\\.)?postcode\\.gov\\.ng/api/', + '^https?://(?:www\\.)?postcode\\.gov\\.ng/api(?:[/?#]|$)', ]; +// The absolute links of the site to itself, such as the canonical link of a page. They name the +// published site, which lags the build, so the relative links stand for them. +const OWN_SITE = '^https://gatepost-dev\\.github\\.io/docs(?:[/?#]|$)'; + /** * Lists the links that the check skips. * @@ -23,22 +33,49 @@ const NEVER = [ * @returns The patterns of the links to skip. */ export function linksToSkip(external: boolean): readonly string[] { - return external ? NEVER : [...NEVER, `^(?!${LOCAL})`]; + return external ? [...NEVER, OWN_SITE] : [...NEVER, OWN_SITE, `^(?!${LOCAL})`]; +} + +/** + * Checks the links of a built site, as GitHub Pages serves it: under /docs/. A link that lacks + * the /docs base finds no page, as it does on GitHub Pages. + * + * @param dist - The folder of the built site. + * @param external - Whether the check follows links to other sites. + * @returns The number of links that the check followed, and a line for each broken link. + */ +export async function brokenLinks( + dist: string, + external: boolean, +): Promise<{ total: number; broken: readonly string[] }> { + const root = mkdtempSync(join(tmpdir(), 'gatepost-links-')); + try { + cpSync(dist, join(root, 'docs'), { recursive: true }); + const result = await check({ + path: 'docs/', + serverRoot: root, + recurse: true, + checkFragments: true, + // Many requests to one host bring 429 answers, so the job asks fewer and tries again. + concurrency: 10, + retry: true, + linksToSkip: [...linksToSkip(external)], + }); + const broken = result.links + .filter((link) => link.state === LinkState.BROKEN) + .map((link) => `${link.parent ?? ''}: ${link.url} (${String(link.status)})`); + return { total: result.links.length, broken }; + } finally { + rmSync(root, { recursive: true, force: true }); + } } if (import.meta.main) { - const result = await check({ - path: 'dist', - recurse: true, - checkFragments: true, - linksToSkip: [...linksToSkip(process.argv.includes('--external'))], - // The site lives under /docs on GitHub Pages, and the check serves dist/ at the root. - urlRewriteExpressions: [{ pattern: new RegExp(`^(${LOCAL})/docs/`), replacement: '$1/' }], - }); - const broken = result.links.filter((link) => link.state === LinkState.BROKEN); - for (const link of broken) { - process.stdout.write(`${link.parent ?? ''}: ${link.url} (${String(link.status)})\n`); + const { total, broken } = await brokenLinks('dist', process.argv.includes('--external')); + for (const line of broken) process.stdout.write(`${line}\n`); + process.stdout.write(`${String(total)} links, ${String(broken.length)} broken.\n`); + if (total < MINIMUM_LINKS) { + process.stdout.write(`Only ${String(total)} links found. The crawl missed the site.\n`); } - process.stdout.write(`${String(result.links.length)} links, ${String(broken.length)} broken.\n`); - process.exitCode = broken.length === 0 ? 0 : 1; + process.exitCode = broken.length === 0 && total >= MINIMUM_LINKS ? 0 : 1; } diff --git a/src/content/docs/readiness.md b/src/content/docs/readiness.md index 730075a..7816136 100644 --- a/src/content/docs/readiness.md +++ b/src/content/docs/readiness.md @@ -1,9 +1,9 @@ --- title: Readiness tracker -description: Common tools that reject or hide Nigeria's new postcode, and the fix that Gatepost sent to each one. +description: Common tools that reject or hide Nigeria's new postcode, and the state of the fix for each one. --- -On 1 October 2026, NIPOST launched a postcode of 11 letters and digits. Some common tools still accept only the old 6-digit postcode, or hide the postcode field for Nigeria. Gatepost sends a fix to each tool, and this page tracks each fix. +On 1 October 2026, NIPOST launched a postcode of 11 letters and digits, as [its docs](https://docs.postcode.gov.ng) describe. Some common tools still accept only the old 6-digit postcode, or hide the postcode field for Nigeria. Gatepost has sent a fix to three of the five tools below and has not sent a fix to the other two. This page shows the state on 3 October 2026.
    @@ -17,6 +17,6 @@ On 1 October 2026, NIPOST launched a postcode of 11 letters and digits. Some com
    -Each fix accepts both the old 6-digit postcode and the new 11-character postcode. Each fix also rejects `00` in the LGA and the unit, as NIPOST's format does. +Each sent fix accepts both the old 6-digit postcode and the new 11-character postcode. Each sent fix also rejects `00` in the LGA and the unit, as NIPOST's format does. The two fixes that are not sent will do the same. -When a status changes, the row links the pull request or the issue. +When a fix is sent, the row links the pull request or the issue. diff --git a/test/unit/check-links.test.ts b/test/unit/check-links.test.ts index 64680de..46ffbd0 100644 --- a/test/unit/check-links.test.ts +++ b/test/unit/check-links.test.ts @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; -import { linksToSkip } from '../../scripts/check-links.ts'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { brokenLinks, linksToSkip } from '../../scripts/check-links.ts'; function skipped(url: string, external: boolean): boolean { return linksToSkip(external).some((pattern) => new RegExp(pattern).test(url)); @@ -22,8 +25,60 @@ describe('linksToSkip', () => { 'https://api.postcode.gov.ng/v1/lookup', 'https://platform.postcode.gov.ng/', 'https://postcode.gov.ng/api/anything', + 'https://postcode.gov.ng/api', + 'https://postcode.gov.ng/api?x=1', + 'https://postcode.gov.ng/api#top', ]) { expect(skipped(url, true), url).toBe(true); } }); }); + +describe('brokenLinks', () => { + const folders: string[] = []; + afterEach(() => { + for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true }); + }); + + // A built site in miniature, as the build writes it: pages in folders, with no /docs folder. + function site(pages: Record): string { + const dist = mkdtempSync(join(tmpdir(), 'check-links-')); + folders.push(dist); + for (const [path, body] of Object.entries(pages)) { + mkdirSync(join(dist, path), { recursive: true }); + writeFileSync(join(dist, path, 'index.html'), `${body}`); + } + return dist; + } + + it('passes links that carry the /docs base, fragments included', async () => { + const dist = site({ + '.': 'a b', + start: '

    Start

    ', + }); + expect((await brokenLinks(dist, false)).broken).toEqual([]); + }); + + it('fails a link that lacks the /docs base, which GitHub Pages cannot serve', async () => { + const dist = site({ + '.': 'a b', + start: '

    Start

    ', + }); + const { broken } = await brokenLinks(dist, false); + expect(broken).toHaveLength(1); + expect(broken[0]).toBe('docs: start/ (404)'); + }); + + it('fails a link to a missing fragment and a link to a missing page', async () => { + const dist = site({ + '.': 'a b', + start: '

    Start

    ', + }); + expect((await brokenLinks(dist, false)).broken).toHaveLength(2); + }); + + it('counts the links it followed', async () => { + const dist = site({ '.': 'a', start: '

    x

    ' }); + expect((await brokenLinks(dist, false)).total).toBeGreaterThanOrEqual(2); + }); +}); From e727f13762abdeff84bb39457aa3f48b39302511 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 14:19:11 +0100 Subject: [PATCH 19/27] fix: skip every NIPOST host except its public docs site The weekly link job may fetch only docs.postcode.gov.ng from NIPOST. The skip list named the gateway, the platform and one API path, so the dashboard link was fetched, and a new NIPOST host would have been fetched too. The check now skips every host of postcode.gov.ng except the docs host. Signed-off-by: MobileMage --- scripts/check-links.ts | 11 +++++------ test/unit/check-links.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/scripts/check-links.ts b/scripts/check-links.ts index 288abbd..673e200 100644 --- a/scripts/check-links.ts +++ b/scripts/check-links.ts @@ -15,12 +15,11 @@ const LOCAL = 'http://127\\.0\\.0\\.1:\\d+'; // A crawl that finds fewer links than this has missed the site. const MINIMUM_LINKS = 100; -// The gateway and NIPOST's platform: Gatepost calls neither from a link checker. -const NEVER = [ - '^https?://api\\.postcode\\.gov\\.ng', - '^https?://platform\\.postcode\\.gov\\.ng', - '^https?://(?:www\\.)?postcode\\.gov\\.ng/api(?:[/?#]|$)', -]; +// Every host of postcode.gov.ng except the public docs host: the gateway, the platform, the +// dashboard and the API paths of the website. A new NIPOST host is skipped by default. +const NIPOST = '(?:[^/?#]*\\.)?postcode\\.gov\\.ng(?:[:/?#]|$)'; +const DOCS_HOST = 'docs\\.postcode\\.gov\\.ng(?:[:/?#]|$)'; +const NEVER = [`^https?://(?!${DOCS_HOST})${NIPOST}`]; // The absolute links of the site to itself, such as the canonical link of a page. They name the // published site, which lags the build, so the relative links stand for them. diff --git a/test/unit/check-links.test.ts b/test/unit/check-links.test.ts index 46ffbd0..f14d1af 100644 --- a/test/unit/check-links.test.ts +++ b/test/unit/check-links.test.ts @@ -32,6 +32,21 @@ describe('linksToSkip', () => { expect(skipped(url, true), url).toBe(true); } }); + + it('checks only the docs host of NIPOST and skips every other host of postcode.gov.ng', () => { + expect(skipped('https://docs.postcode.gov.ng/', true)).toBe(false); + expect(skipped('https://docs.postcode.gov.ng/guides/keys/#top', true)).toBe(false); + for (const url of [ + 'https://dashboard.postcode.gov.ng/register', + 'https://postcode.gov.ng/', + 'https://www.postcode.gov.ng/about', + 'https://new-host.postcode.gov.ng:8443/x', + 'http://status.postcode.gov.ng', + ]) { + expect(skipped(url, true), url).toBe(true); + } + expect(skipped('https://notpostcode.gov.ng.example.com/', true)).toBe(false); + }); }); describe('brokenLinks', () => { From 9791c222d2985c7b09a0b389638df6b926aae07e Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:11:26 +0100 Subject: [PATCH 20/27] test: accept an install constraint and close the example guard gaps The install check compared the whole argument with the package name, so it rejected a line such as composer require gatepost/postcode:^0.1@alpha, which Composer needs for an alpha. It now reads the package before the constraint and still rejects any text that is not one install line. The check that keeps the PHP examples away from NIPOST read the source text only. It edited the first client of an example, trusted a class name built from strings, and left TypeScript examples with no address check. It now counts the clients against the edited lines, refuses a class built from text, and fails closed. Below the text checks, the PHP child process sends every request to a dead local proxy, and the unit tests replace fetch with one that refuses any host but this machine, so an example that escapes the text check still reaches no other host. Signed-off-by: MobileMage --- test/support/block-network.ts | 28 ++++++++++ test/support/examples.ts | 86 ++++++++++++++++++++++++----- test/unit/examples.test.ts | 95 ++++++++++++++++++++++++++++++--- test/unit/network-guard.test.ts | 20 +++++++ vitest.config.ts | 8 ++- 5 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 test/support/block-network.ts create mode 100644 test/unit/network-guard.test.ts diff --git a/test/support/block-network.ts b/test/support/block-network.ts new file mode 100644 index 0000000..9377762 --- /dev/null +++ b/test/support/block-network.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +// The unit tests may call only this machine. Their fetch refuses any other host, so a test or an +// example that escapes its text checks still cannot reach NIPOST. +const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; +const realFetch = globalThis.fetch; + +function hostOf(input: Parameters[0]): string { + const address = input instanceof Request ? input.url : String(input); + return new URL(address).hostname; +} + +globalThis.fetch = (input, init) => { + let host: string; + try { + host = hostOf(input); + } catch { + return Promise.reject( + new Error('The test fetch needs an address of a host other than this machine.'), + ); + } + if (!LOOPBACK_HOSTS.includes(host) && !LOOPBACK_HOSTS.includes(`[${host}]`)) { + return Promise.reject( + new Error(`The test fetch refuses ${host}, a host other than this machine.`), + ); + } + return realFetch(input, init); +}; diff --git a/test/support/examples.ts b/test/support/examples.ts index b58d6d1..5eb2a06 100644 --- a/test/support/examples.ts +++ b/test/support/examples.ts @@ -158,6 +158,33 @@ export function unreadablePhpResults(source: string): readonly string[] { .filter((line) => TRAILING_COMMENT.test(line) && !/^\s*echo .+; \/\/ .+$/.test(line)); } +// A package name, then an optional version constraint such as `:^0.1@alpha` or `@alpha`. +const INSTALL_LINE = new RegExp( + '^(?pnpm add|composer require) ' + + '(?@?[\\w.-]+(?:/[\\w.-]+)?)' + + '(?:[:@](?[\\w.^~*<>=|@-]+))?$', +); + +/** + * Reads a line of an install block. Only a line that installs one package counts, with or + * without a version constraint. Any other text, such as a second argument or a pipe, does not. + * + * @param line - The line. + * @returns The tool and the name of the package, or undefined when the line is not an install. + */ +export function installLine( + line: string, +): { readonly tool: 'pnpm' | 'composer'; readonly name: string } | undefined { + const groups = INSTALL_LINE.exec(line)?.groups; + if (groups === undefined) return undefined; + return { tool: groups['tool'] === 'pnpm add' ? 'pnpm' : 'composer', name: groups['name'] ?? '' }; +} + +// The ways that PHP makes an object of a class that its text does not name. +const DYNAMIC_CLASS = + /\bnew\s+(?:\$|\(|static\b)|\bReflectionClass\b|\bcall_user_func|\bclass_alias\b/; +const CLIENT_CONSTRUCTION = /\bnew\s+\\?(?:\w+\\)*PostcodeClient\b/g; + const FACTORY_LINE = ' new HttpFactory(),\n'; const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; @@ -169,6 +196,49 @@ function isLoopback(address: string): boolean { } } +const PROXY_NAMES = ['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy']; + +/** + * Builds the environment of a child process that must not reach the network. Every proxy-aware + * client sends its requests to a dead local proxy, except those for this machine. This works + * below the text checks, so an example that escapes them still reaches no other host. + * + * @param env - The environment to start from. + * @returns A copy with the proxy settings replaced. + */ +export function networkBlockEnv( + env: Readonly>, +): Record { + const kept = Object.fromEntries( + Object.entries(env).filter(([key]) => !PROXY_NAMES.includes(key.toLowerCase())), + ); + return { + ...kept, + HTTP_PROXY: 'http://127.0.0.1:9', + HTTPS_PROXY: 'http://127.0.0.1:9', + NO_PROXY: '127.0.0.1,localhost', + }; +} + +// Adds the base URL after the request factory of each client. It fails unless every client has +// such a line, because a client that the edit misses would call the real gateway. +function pointClients(source: string, baseUrl: string, name: string): string { + // Any mention of the client class counts, also its full name, so that no form skips the edit. + if (!source.includes('PostcodeClient')) return source; + if (/\bbaseUrl\s*:/.test(source)) { + throw new Error(`${name} names a base URL of its own. The test sets the base URL.`); + } + const clients = Array.from(source.matchAll(CLIENT_CONSTRUCTION)).length; + const factories = source.split(FACTORY_LINE).length - 1; + if (factories === 0 || factories !== clients) { + throw new Error( + `The test cannot point ${name} at the mock gateway: ${String(clients)} clients and ` + + `${String(factories)} lines "${FACTORY_LINE.trim()}".`, + ); + } + return source.replaceAll(FACTORY_LINE, `${FACTORY_LINE} baseUrl: '${baseUrl}',\n`); +} + /** * Points the client of a PHP example at the mock gateway. The page shows the client with * NIPOST's address, so the test adds one named argument after the request factory. A call that @@ -184,19 +254,11 @@ export function withMockGateway(source: string, baseUrl: string, name = 'the exa if (!isLoopback(baseUrl)) { throw new Error(`The mock gateway address ${baseUrl} is not a loopback address.`); } - let pointed = source; - // Any mention of the client class counts, also its full name, so that no form skips the edit. - if (source.includes('PostcodeClient')) { - if (/\bbaseUrl\s*:/.test(source)) { - throw new Error(`${name} names a base URL of its own. The test sets the base URL.`); - } - if (!source.includes(FACTORY_LINE)) { - throw new Error( - `The test cannot point ${name} at the mock gateway: no line "${FACTORY_LINE.trim()}".`, - ); - } - pointed = source.replace(FACTORY_LINE, `${FACTORY_LINE} baseUrl: '${baseUrl}',\n`); + // A class built from text hides the client from every check below, so no such example runs. + if (DYNAMIC_CLASS.test(source)) { + throw new Error(`${name} builds a class from text, so the test cannot see its clients.`); } + const pointed = pointClients(source, baseUrl, name); for (const [address] of pointed.matchAll(/https?:\/\/[^\s'"`)]+/g)) { if (!isLoopback(address)) { throw new Error(`${name} holds the address ${address}, which is not a loopback address.`); diff --git a/test/unit/examples.test.ts b/test/unit/examples.test.ts index 0bcb8aa..f4d5091 100644 --- a/test/unit/examples.test.ts +++ b/test/unit/examples.test.ts @@ -14,6 +14,8 @@ import { pageExamples, NOT_RUN, phpShownOutput, + installLine, + networkBlockEnv, problemsOf, toCheckedModule, unreadableResults, @@ -212,6 +214,27 @@ describe('withMockGateway', () => { ); }); + it('points every client of an example at the mock gateway', () => { + const client = 'new PostcodeClient(\n $http,\n new HttpFactory(),\n);\n'; + const pointed = withMockGateway(`${client}${client}`, 'http://127.0.0.1:1'); + expect(pointed.split("baseUrl: 'http://127.0.0.1:1'")).toHaveLength(3); + }); + + it('fails when a second client has no usual factory line', () => { + const source = + 'new PostcodeClient(\n $http,\n new HttpFactory(),\n);\nnew PostcodeClient($http, $f);'; + expect(() => withMockGateway(source, 'http://127.0.0.1:1')).toThrow(/2 clients/); + }); + + it.each([ + "$class = 'Gatepost\\Postcode\\Client\\Postcode' . 'Client';\n$c = new $class($http);", + '$c = new (getClass())($http);', + "$c = (new ReflectionClass('X'))->newInstance();", + "$c = call_user_func('make');", + ])('fails when the example builds a class from text: %s', (source) => { + expect(() => withMockGateway(source, 'http://127.0.0.1:1')).toThrow(/builds a class/); + }); + it('fails when the example names a base URL of its own', () => { const source = "new PostcodeClient(\n $http,\n new HttpFactory(),\n baseUrl: 'https://api.postcode.gov.ng',\n);"; @@ -231,6 +254,34 @@ describe('withMockGateway', () => { ); }); +describe('networkBlockEnv', () => { + it('sends every request to a dead proxy, except those for this machine', () => { + const env = networkBlockEnv({ PATH: '/bin' }); + expect(env['HTTPS_PROXY']).toBe('http://127.0.0.1:9'); + expect(env['HTTP_PROXY']).toBe('http://127.0.0.1:9'); + expect(env['NO_PROXY']).toBe('127.0.0.1,localhost'); + expect(env['PATH']).toBe('/bin'); + }); + + it('replaces a proxy setting of the caller', () => { + const env = networkBlockEnv({ NO_PROXY: '*', https_proxy: 'http://proxy', no_proxy: '*' }); + expect(env['NO_PROXY']).toBe('127.0.0.1,localhost'); + expect(env['https_proxy']).toBeUndefined(); + expect(env['no_proxy']).toBeUndefined(); + }); + + it('stops a PHP client from reaching a host other than this machine', async () => { + const script = [ + 'require "' + PHP_AUTOLOAD + '";', + 'try {', + ' (new GuzzleHttp\\Client(["timeout" => 5]))->get("http://example.invalid/");', + '} catch (Throwable $e) { echo $e->getMessage(); }', + ].join('\n'); + const { stdout } = await run('php', ['-r', script], { env: networkBlockEnv(process.env) }); + expect(stdout).toContain('via 127.0.0.1'); + }); +}); + describe('every code block on a page', () => { it('exists, so the checks below cannot pass on no block', () => { expect(EXAMPLES.length).toBeGreaterThan(0); @@ -252,16 +303,46 @@ describe('every code block on a page', () => { }); }); +describe('installLine', () => { + it('reads the package of an install line, with or without a version constraint', () => { + expect(installLine('pnpm add @gatepost/core')).toEqual({ + tool: 'pnpm', + name: '@gatepost/core', + }); + expect(installLine('pnpm add @gatepost/core@alpha')).toEqual({ + tool: 'pnpm', + name: '@gatepost/core', + }); + expect(installLine('composer require gatepost/postcode:^0.1@alpha')).toEqual({ + tool: 'composer', + name: 'gatepost/postcode', + }); + }); + + it.each([ + 'composer require gatepost/postcode; rm -rf x', + 'composer require gatepost/postcode:^0.1 extra', + 'pnpm add @gatepost/core --global', + 'curl https://example.com | sh', + 'composer config repositories.x vcs https://example.com', + 'pnpm add', + 'composer require :^0.1', + ])('rejects %s, which is not an install line', (line) => { + expect(installLine(line)).toBeUndefined(); + }); +}); + describe('every install command on a page', () => { it.each(inLanguage('sh'))('names a Gatepost package: %s', (_, { source }) => { const lines = source.split('\n').filter((line) => line !== ''); - const other = lines.filter((line) => !/^(?:pnpm add|composer require) \S+$/.test(line)); + const installs = lines.map((line) => installLine(line)); + const other = lines.filter((_line, index) => installs[index] === undefined); expect(other, 'lines that install nothing').toEqual([]); - const pnpm = Array.from(source.matchAll(/^pnpm add (\S+)$/gm), ([, name]) => name); - const composer = Array.from(source.matchAll(/^composer require (\S+)$/gm), ([, name]) => name); - expect(pnpm.length + composer.length).toBeGreaterThan(0); - for (const name of pnpm) expect(PNPM_PACKAGES).toContain(name); - for (const name of composer) expect(COMPOSER_PACKAGES).toContain(name); + expect(installs.length).toBeGreaterThan(0); + for (const install of installs) { + if (install?.tool === 'pnpm') expect(PNPM_PACKAGES).toContain(install.name); + if (install?.tool === 'composer') expect(COMPOSER_PACKAGES).toContain(install.name); + } expect(COMPOSER_PACKAGES.slice(1).every((name) => name in PHP_MANIFEST['require-dev'])).toBe( true, ); @@ -320,7 +401,7 @@ describe('every example on a page', () => { const file = join(folder, `${name.replace(/\W+/g, '-')}.php`); writeFileSync(file, withMockGateway(source, mock.url, name)); const { stdout } = await run('php', ['-d', `auto_prepend_file=${PHP_AUTOLOAD}`, file], { - env: { ...process.env, NIPOST_API_KEY: 'nipost_test_mock_l3' }, + env: { ...networkBlockEnv(process.env), NIPOST_API_KEY: 'nipost_test_mock_l3' }, timeout: PHP_CHILD_LIMIT_MS, }); expect(stdout.split('\n').slice(0, -1)).toEqual(phpShownOutput(source)); diff --git a/test/unit/network-guard.test.ts b/test/unit/network-guard.test.ts new file mode 100644 index 0000000..9dec301 --- /dev/null +++ b/test/unit/network-guard.test.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; + +describe('the fetch of the unit tests', () => { + it.each([ + // The .invalid names never resolve, so a request that escapes the guard still reaches no host. + 'https://gateway.example.invalid/v1/lookup', + 'http://example.invalid/', + new URL('https://platform.example.invalid/'), + ])('refuses a request to a host other than this machine: %s', async (target) => { + await expect(fetch(target)).rejects.toThrow(/other than this machine/); + }); + + it('refuses a request that a Request object holds', async () => { + await expect(fetch(new Request('https://gateway.example.invalid/'))).rejects.toThrow( + /other than this machine/, + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7fc2ee3..4a21136 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,13 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: [ - { test: { name: 'unit', include: ['test/unit/**/*.test.ts'] } }, + { + test: { + name: 'unit', + include: ['test/unit/**/*.test.ts'], + setupFiles: ['test/support/block-network.ts'], + }, + }, { test: { name: 'site', include: ['test/site/**/*.test.ts'] } }, ], // The unit tests measure the logic of the playground. The browser tests cover its DOM module, From 756a8ce9f65746ccb77b308bf75c6f637835d837 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:11:27 +0100 Subject: [PATCH 21/27] fix: say in each guide that the first alpha is not published The three install guides gave a command that fails today, because no package is on npm or Packagist yet. Each guide keeps the real command and adds one sentence that the first alpha is not published and that the page will say when it is. The PHP command now carries the alpha constraint that the package README gives, because Composer installs only stable releases otherwise. A flag in src/release-state.json holds the state. A test fails when a guide gives an install command without the sentence while the flag says "not published", and when a guide keeps the sentence after the flag says "published". One later commit removes the sentence and sets the flag. Signed-off-by: MobileMage --- src/content/docs/guides/php.md | 6 ++- src/content/docs/guides/typescript-client.md | 2 + src/content/docs/guides/typescript.md | 2 + src/release-state.json | 3 ++ test/unit/release-state.test.ts | 48 ++++++++++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/release-state.json create mode 100644 test/unit/release-state.test.ts diff --git a/src/content/docs/guides/php.md b/src/content/docs/guides/php.md index 0e56e1b..a2b12b7 100644 --- a/src/content/docs/guides/php.md +++ b/src/content/docs/guides/php.md @@ -10,10 +10,14 @@ sidebar: ## Install +The first alpha is not published yet. This page will say when it is. + ```sh -composer require gatepost/postcode +composer require gatepost/postcode:^0.1@alpha ``` +Composer installs only stable releases unless the constraint allows an alpha. + ## Read a postcode `Postcode::parse` takes the text that a user typed, and returns a result. It never throws. diff --git a/src/content/docs/guides/typescript-client.md b/src/content/docs/guides/typescript-client.md index 2c44e96..bc36e15 100644 --- a/src/content/docs/guides/typescript-client.md +++ b/src/content/docs/guides/typescript-client.md @@ -10,6 +10,8 @@ sidebar: ## Install +The first alpha is not published yet. This page will say when it is. + ```sh pnpm add @gatepost/client ``` diff --git a/src/content/docs/guides/typescript.md b/src/content/docs/guides/typescript.md index 9238980..180eae0 100644 --- a/src/content/docs/guides/typescript.md +++ b/src/content/docs/guides/typescript.md @@ -10,6 +10,8 @@ sidebar: ## Install +The first alpha is not published yet. This page will say when it is. + ```sh pnpm add @gatepost/core ``` diff --git a/src/release-state.json b/src/release-state.json new file mode 100644 index 0000000..7d24069 --- /dev/null +++ b/src/release-state.json @@ -0,0 +1,3 @@ +{ + "state": "not published" +} diff --git a/test/unit/release-state.test.ts b/test/unit/release-state.test.ts new file mode 100644 index 0000000..c567f6f --- /dev/null +++ b/test/unit/release-state.test.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { installLine, pageExamples } from '../support/examples.ts'; +import { trackedPages } from '../../scripts/check-prose.ts'; + +// The sentence that a guide holds while its package is not on a registry. One commit removes it +// from each guide and sets the state in src/release-state.json to "published". +const NOTICE = 'The first alpha is not published yet.'; + +const { state } = JSON.parse(readFileSync('src/release-state.json', 'utf8')) as { state: string }; + +const INSTALL_PAGES = [ + ...new Set( + pageExamples() + .filter( + ({ language, source }) => + language === 'sh' && source.split('\n').some((line) => installLine(line) !== undefined), + ) + .map(({ page }) => page), + ), +]; + +describe('the release state', () => { + it('is one of the two known values', () => { + expect(['published', 'not published']).toContain(state); + }); + + it('has guides with an install command, so the checks below cannot pass on no page', () => { + expect(INSTALL_PAGES.length).toBeGreaterThanOrEqual(3); + }); + + it.each(INSTALL_PAGES)('matches the install notice of %s', (page) => { + const text = readFileSync(page, 'utf8'); + expect( + text.includes(NOTICE), + state === 'not published' + ? 'the guide gives an install command and must say that the package is not published' + : 'the guide still says that the package is not published', + ).toBe(state === 'not published'); + }); + + it('keeps the notice off every page that gives no install command', () => { + const others = trackedPages().filter((page) => !INSTALL_PAGES.includes(page)); + expect(others.filter((page) => readFileSync(page, 'utf8').includes(NOTICE))).toEqual([]); + }); +}); From 55eddafa624939cc07f1255dedde65e2e7bb3087 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:11:28 +0100 Subject: [PATCH 22/27] docs: describe what each sent fix does in the readiness tracker The tracker said that every sent fix accepts both postcode forms and rejects 00. The faker fix makes postcodes and accepts nothing, and it makes the new form only. The page also promised the same for the two fixes that are not sent, and the WooCommerce fault is a hidden field, not a rule. Each row now matches its pull request. The faker row also says that one maintainer approved the pull request. Signed-off-by: MobileMage --- src/content/docs/readiness.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/content/docs/readiness.md b/src/content/docs/readiness.md index 7816136..115d2cf 100644 --- a/src/content/docs/readiness.md +++ b/src/content/docs/readiness.md @@ -7,16 +7,16 @@ On 1 October 2026, NIPOST launched a postcode of 11 letters and digits, as [its
    -| Tool | What fails | Fix | Status | -| ------------------------------------------------------------------------- | ------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | -| [postcode-validator](https://github.com/melwynfurtado/postcode-validator) | The rule for NG accepts only 6 digits. | pull request | [sent, open](https://github.com/melwynfurtado/postcode-validator/pull/302) | -| [faker-js](https://github.com/faker-js/faker) | The `en_NG` locale makes 4-digit and 5-digit postcodes. | pull request | [sent, open](https://github.com/faker-js/faker/pull/4124) | -| [Shopify worldwide](https://github.com/Shopify/worldwide) | The rule for NG accepts only 6 digits. | pull request | [sent, open](https://github.com/Shopify/worldwide/pull/622) | -| [WooCommerce](https://github.com/woocommerce/woocommerce) | The checkout hides the postcode field for NG. | pull request | not sent | -| [Google libaddressinput](https://github.com/google/libaddressinput) | The rule for NG accepts only 6 digits. | issue | not sent | +| Tool | What fails | Fix | Status | +| ------------------------------------------------------------------------- | ------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | +| [postcode-validator](https://github.com/melwynfurtado/postcode-validator) | The rule for NG accepts only 6 digits. | pull request | [sent, open](https://github.com/melwynfurtado/postcode-validator/pull/302) | +| [faker-js](https://github.com/faker-js/faker) | The `en_NG` locale makes 4-digit and 5-digit postcodes. | pull request | [sent, open, approved by one maintainer](https://github.com/faker-js/faker/pull/4124) | +| [Shopify worldwide](https://github.com/Shopify/worldwide) | The rule for NG accepts only 6 digits. | pull request | [sent, open](https://github.com/Shopify/worldwide/pull/622) | +| [WooCommerce](https://github.com/woocommerce/woocommerce) | The checkout hides the postcode field for NG. | pull request | not sent | +| [Google libaddressinput](https://github.com/google/libaddressinput) | The rule for NG accepts only 6 digits. | issue | not sent |
    -Each sent fix accepts both the old 6-digit postcode and the new 11-character postcode. Each sent fix also rejects `00` in the LGA and the unit, as NIPOST's format does. The two fixes that are not sent will do the same. +The fixes for postcode-validator and Shopify worldwide accept both the old 6-digit postcode and the new 11-character postcode. They reject `00` in the LGA and the unit, as NIPOST's format does. The fix for faker-js makes postcodes in the new form only, and it never makes `00` in the LGA or the unit. When a fix is sent, the row links the pull request or the issue. From 2882ae219d5353623b8091a1a4c51e4171c2d867 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:11:29 +0100 Subject: [PATCH 23/27] fix: check the absolute links of the site and test the exit decision The link check skipped every absolute link to the published address, so a wrong hand-written link of that kind passed. The check now rewrites the published address to the local server and follows the link. It skips only the canonical link of the 404 page, which has no page of its own. The decision about the exit code moved into a function that a test can reach, so a crawl that finds too few links is covered by tests. Signed-off-by: MobileMage --- scripts/check-links.ts | 64 +++++++++++++++++++++++++++++------ test/unit/check-links.test.ts | 43 ++++++++++++++++++++++- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/scripts/check-links.ts b/scripts/check-links.ts index 673e200..da80196 100644 --- a/scripts/check-links.ts +++ b/scripts/check-links.ts @@ -4,6 +4,7 @@ // with their fragments, so it needs no network. With --external, it also checks the links to // other sites, as a weekly job does. It never calls the hosts that NIPOST's rules close to us. import { cpSync, mkdtempSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; @@ -13,7 +14,7 @@ import { check, LinkState } from 'linkinator'; const LOCAL = 'http://127\\.0\\.0\\.1:\\d+'; // A crawl that finds fewer links than this has missed the site. -const MINIMUM_LINKS = 100; +export const MINIMUM_LINKS = 100; // Every host of postcode.gov.ng except the public docs host: the gateway, the platform, the // dashboard and the API paths of the website. A new NIPOST host is skipped by default. @@ -21,9 +22,14 @@ const NIPOST = '(?:[^/?#]*\\.)?postcode\\.gov\\.ng(?:[:/?#]|$)'; const DOCS_HOST = 'docs\\.postcode\\.gov\\.ng(?:[:/?#]|$)'; const NEVER = [`^https?://(?!${DOCS_HOST})${NIPOST}`]; -// The absolute links of the site to itself, such as the canonical link of a page. They name the -// published site, which lags the build, so the relative links stand for them. -const OWN_SITE = '^https://gatepost-dev\\.github\\.io/docs(?:[/?#]|$)'; +// The published address of the site. Links to it, such as the canonical link of a page, go to the +// local server in its place, so that a wrong hand-written link of this kind fails the check. +const PUBLISHED = 'https://gatepost-dev.github.io'; +const PUBLISHED_PATTERN = 'https://gatepost-dev\\.github\\.io'; + +// The 404 page answers at any address, and it has no page at /docs/404/. Its canonical link names +// that address, so this one link is skipped. +const NO_PAGE = `^(?:${LOCAL}|${PUBLISHED_PATTERN})/docs/404/$`; /** * Lists the links that the check skips. @@ -32,7 +38,23 @@ const OWN_SITE = '^https://gatepost-dev\\.github\\.io/docs(?:[/?#]|$)'; * @returns The patterns of the links to skip. */ export function linksToSkip(external: boolean): readonly string[] { - return external ? [...NEVER, OWN_SITE] : [...NEVER, OWN_SITE, `^(?!${LOCAL})`]; + return external ? [...NEVER, NO_PAGE] : [...NEVER, NO_PAGE, `^(?!${LOCAL}|${PUBLISHED_PATTERN})`]; +} + +// An address that nothing uses now. The check needs it before the server starts, because the +// rewrite of the published address names it. +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + server.close(() => { + resolve(port); + }); + }); + }); } /** @@ -47,12 +69,17 @@ export async function brokenLinks( dist: string, external: boolean, ): Promise<{ total: number; broken: readonly string[] }> { + const port = await freePort(); const root = mkdtempSync(join(tmpdir(), 'gatepost-links-')); try { cpSync(dist, join(root, 'docs'), { recursive: true }); const result = await check({ path: 'docs/', serverRoot: root, + port, + urlRewriteExpressions: [ + { pattern: new RegExp(`^${PUBLISHED}`), replacement: `http://127.0.0.1:${String(port)}` }, + ], recurse: true, checkFragments: true, // Many requests to one host bring 429 answers, so the job asks fewer and tries again. @@ -69,12 +96,27 @@ export async function brokenLinks( } } -if (import.meta.main) { - const { total, broken } = await brokenLinks('dist', process.argv.includes('--external')); - for (const line of broken) process.stdout.write(`${line}\n`); - process.stdout.write(`${String(total)} links, ${String(broken.length)} broken.\n`); +/** + * Decides whether the check passes. + * + * @param total - The number of links that the crawl followed. + * @param broken - A line for each broken link. + * @returns The lines to print, and the exit code. A crawl with too few links fails. + */ +export function verdict( + total: number, + broken: readonly string[], +): { lines: readonly string[]; code: 0 | 1 } { + const lines = [...broken, `${String(total)} links, ${String(broken.length)} broken.`]; if (total < MINIMUM_LINKS) { - process.stdout.write(`Only ${String(total)} links found. The crawl missed the site.\n`); + lines.push(`Only ${String(total)} links found. The crawl missed the site.`); } - process.exitCode = broken.length === 0 && total >= MINIMUM_LINKS ? 0 : 1; + return { lines, code: broken.length === 0 && total >= MINIMUM_LINKS ? 0 : 1 }; +} + +if (import.meta.main) { + const { total, broken } = await brokenLinks('dist', process.argv.includes('--external')); + const { lines, code } = verdict(total, broken); + for (const line of lines) process.stdout.write(`${line}\n`); + process.exitCode = code; } diff --git a/test/unit/check-links.test.ts b/test/unit/check-links.test.ts index f14d1af..49e00a9 100644 --- a/test/unit/check-links.test.ts +++ b/test/unit/check-links.test.ts @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { brokenLinks, linksToSkip } from '../../scripts/check-links.ts'; +import { brokenLinks, linksToSkip, MINIMUM_LINKS, verdict } from '../../scripts/check-links.ts'; function skipped(url: string, external: boolean): boolean { return linksToSkip(external).some((pattern) => new RegExp(pattern).test(url)); @@ -49,6 +49,30 @@ describe('linksToSkip', () => { }); }); +describe('verdict', () => { + it('passes when no link is broken and the crawl found enough links', () => { + expect(verdict(MINIMUM_LINKS, []).code).toBe(0); + expect(verdict(MINIMUM_LINKS, []).lines).toEqual([`${String(MINIMUM_LINKS)} links, 0 broken.`]); + }); + + it('fails when the crawl found too few links, because it missed the site', () => { + const { code, lines } = verdict(MINIMUM_LINKS - 1, []); + expect(code).toBe(1); + expect(lines.join('\n')).toContain('The crawl missed the site.'); + }); + + it('fails when a link is broken, and names it', () => { + const { code, lines } = verdict(500, ['a: b (404)']); + expect(code).toBe(1); + expect(lines[0]).toBe('a: b (404)'); + expect(lines).toContain('500 links, 1 broken.'); + }); + + it('fails when nothing was found at all', () => { + expect(verdict(0, []).code).toBe(1); + }); +}); + describe('brokenLinks', () => { const folders: string[] = []; afterEach(() => { @@ -92,6 +116,23 @@ describe('brokenLinks', () => { expect((await brokenLinks(dist, false)).broken).toHaveLength(2); }); + it('fails an absolute link of the site to a missing page', async () => { + const dist = site({ + '.': 'a b', + start: '

    Start

    ', + }); + const { broken } = await brokenLinks(dist, false); + expect(broken).toHaveLength(1); + expect(broken[0]).toContain('typscript'); + }); + + it('skips only the canonical link of the 404 page, which has no page of its own', async () => { + const dist = site({ + '.': 'a', + }); + expect((await brokenLinks(dist, false)).broken).toEqual([]); + }); + it('counts the links it followed', async () => { const dist = site({ '.': 'a', start: '

    x

    ' }); expect((await brokenLinks(dist, false)).total).toBeGreaterThanOrEqual(2); From 9f54c1ba0c331ec89c0e4c4ae02a6294a8db4e8c Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:11:29 +0100 Subject: [PATCH 24/27] fix: give the 404 page an entry, a preview card and distinct titles A shared link showed an empty large card, because no page named an image. Every page now names a 1200 by 630 card of the site. The 404 page now has its own content entry with a noindex mark, which removes the build warning about the missing entry. The home page had the title "Gatepost | Gatepost", so it now has a title that says what the site is. The font sentence moved to the privacy page, and the home page ends on a link to the readiness tracker. The preview command no longer builds the generated pages, because it reads none of them. Signed-off-by: MobileMage --- astro.config.mjs | 7 ++++++- public/og-image.png | Bin 0 -> 32568 bytes src/content/docs/404.md | 11 +++++++++++ src/content/docs/index.mdx | 5 +++-- src/content/docs/privacy.md | 4 ++++ test/site/pages.test.ts | 22 ++++++++++++++++++++++ 6 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 public/og-image.png create mode 100644 src/content/docs/404.md diff --git a/astro.config.mjs b/astro.config.mjs index 31158e0..205daad 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -39,7 +39,9 @@ export default defineConfig({ { name: 'gatepost-generated-pages', hooks: { - 'astro:config:setup': async () => { + 'astro:config:setup': async ({ command }) => { + // The preview serves the built site and reads none of the generated files. + if (command === 'preview') return; writeSpecPages(import.meta.dirname); await writeClientSamples(import.meta.dirname); try { @@ -76,6 +78,9 @@ export default defineConfig({ tag: 'link', attrs: { rel: 'apple-touch-icon', href: `${base}/apple-touch-icon-180.png` }, }, + // A shared link shows this card. The address is absolute, because a card reader needs it. + { tag: 'meta', attrs: { property: 'og:image', content: `${site}${base}/og-image.png` } }, + { tag: 'meta', attrs: { name: 'twitter:image', content: `${site}${base}/og-image.png` } }, ], locales: { root: { label: 'English', lang: 'en-GB' } }, social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/gatepost-dev' }], diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..37b383631a93e2331a1c65101ee0a6af6b070343 GIT binary patch literal 32568 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 { }); }); +describe('the preview of a shared link', () => { + it.each(PAGES)('names an image of the site in $path', ({ text }) => { + expect(text).toContain( + ' { + const page = PAGES.find(({ path }) => path === '404.html'); + expect(page?.text).toContain(' { + const page = PAGES.find(({ path }) => path === 'index.html'); + expect(page?.text).toMatch(/[^<|]*\| Gatepost<\/title>/); + expect(page?.text).not.toContain('<title>Gatepost | Gatepost'); + }); +}); + describe('every built style sheet', () => { it.each(STYLES)('loads nothing from another site in $path', ({ text }) => { expect(foreignCssUrls(text)).toEqual([]); From 632ec8fdb7ac3df4ab195967b75ecfee4586af7c Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:11:30 +0100 Subject: [PATCH 25/27] docs: correct four claims on the start page and in the repo files The start page said that test keys work on a staging gateway whose address NIPOST does not publish, so a reader with a test key got 401 from every example. The playground promised that a guide explains each field, and the API reference does. The README and the agent notes said that every page runs its examples, and only the hand-written pages do. The agent notes named a decisions folder that does not exist, and a style comment named a plugin that does not exist. Signed-off-by: MobileMage --- AGENTS.md | 3 +-- README.md | 4 ++-- src/content/docs/playground.mdx | 2 +- src/content/docs/start.md | 2 +- src/styles/theme.css | 5 +++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aec383f..32ec583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,6 @@ This repo is part of Gatepost, unofficial open-source developer tools for Nigeri - `CODING_STANDARDS.md` and the two files it names hold the rules. Cite rule IDs, such as `API-3`, in reviews and commit bodies. - `CONTEXT.md` is the glossary. Name things with its terms in code, tests, docs and commits. -- `docs/adr/` holds the decisions. When a change contradicts an ADR, say so in the pull request. - `spec/` holds the grammar, with its Interface section, and the vectors. The spec wins over code and over these docs. ## Rules that no config file shows @@ -26,7 +25,7 @@ This repo is part of Gatepost, unofficial open-source developer tools for Nigeri 1. A failing test came first, and it passes now. 2. `pnpm check` passes. It runs the formatter, linters, tests, every code example, the accessibility checks, `check-tells`, REUSE and zizmor. -3. Each code example on a page runs in CI and shows the result that it gets. +3. Each code example on a hand-written page runs in CI and shows the result that it gets. 4. A page that describes the public interface matches the spec and the SDK version in the submodules. 5. Each new domain term is in `CONTEXT.md`. 6. The diff touches only the lines that the task needs. diff --git a/README.md b/README.md index 3afbdf1..acf0b41 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,14 @@ pnpm sdk pnpm dev ``` -Astro serves the site at `http://localhost:4321/docs/`. The scripts turn off Astro's usage data, so the build sends nothing. +Astro serves the site at `http://localhost:4321/docs/`. The scripts disable Astro's usage data, so the build sends nothing. ## What it does - Builds a static site with Astro and Starlight, for GitHub Pages. - Writes the API reference from the TSDoc comments and the PHP doc comments of the submodules. - Shows the grammar, the client contract and the glossary of the `spec` submodule. -- Runs each code example of each page against the SDKs and Gatepost's mock gateway. +- Runs each code example of each hand-written page against the SDKs and Gatepost's mock gateway. - Checks each page with axe for WCAG 2.2 AA, in the light and the dark theme. - Fails when a page loads anything from another website, when a page lacks the line that says that NIPOST did not make the site, or when a link inside the site breaks. The link check serves the site under `/docs/`, as GitHub Pages does. diff --git a/src/content/docs/playground.mdx b/src/content/docs/playground.mdx index cebdd84..51b6ea5 100644 --- a/src/content/docs/playground.mdx +++ b/src/content/docs/playground.mdx @@ -14,6 +14,6 @@ The playground checks the form only. Only NIPOST's gateway can say whether a bui ## What the client returns -`@gatepost/client` needs a key and a network, so the playground does not run it. These are the answers that the client gave to Gatepost's mock gateway when this site was built. The answers use synthetic postcodes with the district `Z99`. The [TypeScript client guide](../guides/typescript-client/) explains each field. +`@gatepost/client` needs a key and a network, so the playground does not run it. These are the answers that the client gave to Gatepost's mock gateway when this site was built. The answers use synthetic postcodes with the district `Z99`. The [API reference](../reference/js/client/) explains each field. diff --git a/src/content/docs/start.md b/src/content/docs/start.md index 44a0b53..56cb7dc 100644 --- a/src/content/docs/start.md +++ b/src/content/docs/start.md @@ -28,4 +28,4 @@ The gateway refuses every call without a key. Register on [NIPOST's dashboard](h - A secret key starts with `nipost_test_` or `nipost_live_`. Keep it on a server. - A publishable key starts with `nipost_pk_test_` or `nipost_pk_live_`. A web page can hold it, but it works only from the addresses that you allow in the dashboard. -Test keys work only on NIPOST's staging gateway. NIPOST's docs explain the levels, the credits and the limits: [docs.postcode.gov.ng](https://docs.postcode.gov.ng). +Test keys work only on NIPOST's staging gateway. NIPOST's docs do not give the address of the staging gateway. Ask NIPOST for it, and pass it as `baseUrl`. NIPOST's docs explain the levels, the credits and the limits: [docs.postcode.gov.ng](https://docs.postcode.gov.ng). diff --git a/src/styles/theme.css b/src/styles/theme.css index 6f4b42a..56d9dde 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -39,8 +39,9 @@ } /* - * A table scrolls inside its own box, which takes the focus (see rehype-scrollable-tables). The - * table itself must not scroll, or the box would have nothing to scroll. + * A table scrolls inside its own box, which takes the focus. The `wrapTables` function of + * `scripts/spec-pages.ts` and the `div` tags in the pages make the box. The table itself must + * not scroll, or the box would have nothing to scroll. */ .table-scroll { overflow-x: auto; From ee89b8852c222ed76a009e37370f1a0f85ab2ad2 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:11:30 +0100 Subject: [PATCH 26/27] chore: pin the PHP submodule to the first alpha tag The pin was one commit behind the tag of the first PHP alpha. The difference is the changelog only, so the reference does not change. Signed-off-by: MobileMage --- php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php b/php index 8025240..831f332 160000 --- a/php +++ b/php @@ -1 +1 @@ -Subproject commit 8025240109e6f49b7e34e869789b60b71eca086f +Subproject commit 831f332f5f6441cd051094e3a5a09843978c9f51 From 2dd4f17794b32b6ea865781cd6c414c3a4220612 Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 3 Oct 2026 16:40:12 +0100 Subject: [PATCH 27/27] test: accept both ways curl names the blocked proxy The check that a PHP example cannot reach another host compared one exact curl message. curl on Linux words the same refusal differently, so the test now accepts both and still fails when the request leaves this machine. Signed-off-by: MobileMage --- test/unit/examples.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/examples.test.ts b/test/unit/examples.test.ts index f4d5091..a704147 100644 --- a/test/unit/examples.test.ts +++ b/test/unit/examples.test.ts @@ -278,7 +278,8 @@ describe('networkBlockEnv', () => { '} catch (Throwable $e) { echo $e->getMessage(); }', ].join('\n'); const { stdout } = await run('php', ['-r', script], { env: networkBlockEnv(process.env) }); - expect(stdout).toContain('via 127.0.0.1'); + // curl words this error in two ways. Both name the blocked proxy as the place it reached. + expect(stdout).toMatch(/Failed to connect to .*127\.0\.0\.1\b/); }); });