diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6578359..3ea0575 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.13 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -40,6 +40,9 @@ jobs: - name: Lint run: bun run lint + - name: Skill bundles are up to date + run: bun run build:skill:check + - name: Test (with coverage) run: bun run test:coverage diff --git a/.please/docs/knowledge/gotchas.md b/.please/docs/knowledge/gotchas.md index 72a6389..ca0501d 100644 --- a/.please/docs/knowledge/gotchas.md +++ b/.please/docs/knowledge/gotchas.md @@ -6,7 +6,11 @@ - **`bun run lint` requires Node 22+ in CI** — the eslint binary uses `#!/usr/bin/env node`. The Bun setup action does not install Node. Add `actions/setup-node@v4` before `oven-sh/setup-bun@v2`. Older Node (≤20) lacks `Object.groupBy` used by `eslint-flat-config-utils`. Local dev is fine because Node 22+ is usually already installed. -- **Bun 1.3+ writes text-format `bun.lock`** (not legacy `bun.lockb`). `package.json` must pin `engines.bun: ">=1.3.0"`; lower Bun versions cannot read the new lockfile and will fail `bun install --frozen-lockfile`. Documented in `tech-stack.md` § Runtime. +- **Bun 1.3+ writes text-format `bun.lock`** (not legacy `bun.lockb`); lower Bun versions cannot read the new lockfile and will fail `bun install --frozen-lockfile`. That is the floor the lockfile needs — `engines.bun` sits higher (`>=1.4.2`), because the committed skill bundles are byte-compared against CI's Bun (see below). Documented in `tech-stack.md` § Runtime. + +- **Committed skill bundles are Bun-version-coupled** — `bun run build:skill:check` byte-compares a fresh bundle against the committed one, so a Bun whose codegen differs from CI's pin fails the gate on an untouched source tree. Keep the local Bun and `ci.yml`'s `bun-version` on the same version; after bumping either, run `bun run build:skill` and commit the result, and raise `engines.bun` to match so a contributor on an older Bun is told before the gate tells them. + +- **`import.meta.main` is not portable through a bundle** — Bun lowers it to a `__require` comparison that resolves only when some dependency happens to pull in the CJS interop helper. `detect.js` worked by accident (fast-xml-parser supplied it) while `docs.js` threw `ReferenceError: __require is not defined` under plain `node`. `scripts/build-skill.ts` pins it with `define: { 'import.meta.main': 'true' }`; a bundle is always the entrypoint. - **`@pleaseai/eslint-config` includes formatting** — designed standalone, no Prettier. Auto-format applies: no semicolons, single quotes, sorted JSON keys. Adopt early or expect a cascade reformat across every committed file. Do not also install Prettier. diff --git a/.please/docs/knowledge/tech-stack.md b/.please/docs/knowledge/tech-stack.md index fa9e372..feb1930 100644 --- a/.please/docs/knowledge/tech-stack.md +++ b/.please/docs/knowledge/tech-stack.md @@ -8,7 +8,7 @@ - **What**: JavaScript runtime for executing plugin scripts. - **Why**: Fast startup (~10ms vs ~80ms for Node), built-in TypeScript without a bundler, native fetch/glob APIs. - **Where**: `scripts/*.ts` files invoked by skills and slash commands. -- **Version target**: latest stable (≥1.3). The committed `bun.lock` uses Bun's text-format lockfile (default since 1.3); Bun 1.1.x cannot read it. `package.json` `engines.bun` enforces this floor. +- **Version target**: latest stable. The committed `bun.lock` uses Bun's text-format lockfile (default since 1.3); Bun 1.1.x cannot read it. `engines.bun` sits above that at `>=1.4.2`, matching `ci.yml`'s pin, because `build:skill:check` byte-compares the committed bundles against CI's codegen — an older Bun regenerates them differently and fails the gate on an untouched tree. ### Node fallback - **Why**: Some users may not have Bun. Scripts should not rely on Bun-only globals (`Bun.file`, `Bun.serve`) unless the README documents a Bun requirement. @@ -23,13 +23,21 @@ ## Plugin Architecture -### Claude Code plugin conventions -- Manifest at `.claude-plugin/plugin.json` (only file in that dir). -- Components (`skills/`, `commands/`, `scripts/`) at plugin root. -- Path references use `${CLAUDE_PLUGIN_ROOT}` inside skills and scripts. +### Two install channels +The skill ships both as a Claude Code plugin and as a standalone skill installed +with `npx skills` ([vercel-labs/skills](https://github.com/vercel-labs/skills)). +The second channel copies **only** the `skills/spring-docs/` directory, so it has +no plugin root, no `node_modules`, and no dependency install. Everything the +skill executes at runtime therefore lives inside its own directory. -### Slash commands -Each slash command is a thin Markdown file in `commands/` that references a skill or invokes a script. Commands are entry points only — logic lives in skills/scripts. +- Manifest at `.claude-plugin/plugin.json` (only file in that dir). +- TypeScript sources stay at the plugin root (`scripts/`) where tests, typecheck + and lint reach them. +- `bun run build:skill` bundles them into `skills/spring-docs/scripts/*.mjs` — + dependency-free, committed, and run with `node`. +- Skill content references scripts through `${CLAUDE_SKILL_DIR}`, which resolves + at the personal, project **and** plugin level. `${CLAUDE_PLUGIN_ROOT}` is + substituted only in plugin skills, so it cannot be used here. ### Skills Skills (`skills/spring-installer/SKILL.md`) describe behavior and reference scripts. Auto-invoked by Claude Code when the conversation matches the skill description. @@ -71,12 +79,10 @@ Skills (`skills/spring-installer/SKILL.md`) describe behavior and reference scri ## Logging -### `consola` -- **What**: structured CLI logger (https://github.com/unjs/consola). -- **Why**: project-wide consistent log levels, `--verbose` / `--silent` toggling, prompt helpers, child loggers per module — all out of the box. Replaces the `log()` helper originally proposed in `ARCHITECTURE.md`. Tiny footprint, no native deps, ESM-first. -- **Where**: orchestration scripts (`scripts/*.ts`); the I/O-free library layer (`scripts/lib/*`) must remain logger-free. -- **Usage**: import the project-level instance from a single helper (e.g. `scripts/logger.ts` once introduced — outside the I/O-free `scripts/lib/` boundary), so log level / format are applied consistently. Tests should not import consola directly. -- **Note**: this entry supersedes the "no logger library" line in `ARCHITECTURE.md`; that file will be revised in the `arch-md-v2` track. +Scripts print JSON on stdout and nothing else — there is no logger library. +`consola` was adopted during the scaffold track and removed once the skill +bundles had to stay dependency-free (TD-001); a logger would be inlined into +every bundle for output the skill's callers parse as JSON anyway. ## Distribution @@ -125,7 +131,7 @@ bun run scripts/fetch.ts framework 6.2.1 --output /tmp/spring-framework-6.2.1 ## Out of Stack -- **No bundler** — `bun` runs `.ts` directly. +- **No bundler for the plugin channel** — `bun` runs `.ts` directly. `Bun.build` is used for one thing only: the committed skill bundles the standalone channel needs (see § Two install channels). - **No frontend framework** — there is no UI; all output is terminal/files. - **No database** — caches use the filesystem under `~/.cache/pleaseai-spring/`. - **No long-running server** — every command is a one-shot invocation. diff --git a/.please/docs/tracks/tech-debt-tracker.md b/.please/docs/tracks/tech-debt-tracker.md index 1effdd7..dd1077d 100644 --- a/.please/docs/tracks/tech-debt-tracker.md +++ b/.please/docs/tracks/tech-debt-tracker.md @@ -6,7 +6,6 @@ | ID | Source Track | Description | Priority | Created | |----|------------|-------------|----------|---------| -| TD-001 | plugin-scaffold-20260428 | `consola@3.4.2` adopted as runtime dep but no call site yet. First feature track must consume it or remove it. | low | 2026-04-29 | | TD-002 | plugin-scaffold-20260428 | `README.md` "## Development" still references `bun run scripts/fetch.ts` and prebuilt-pipeline artifacts that don't exist (spec forbade rewriting). Reconcile after `arch-md-v2-20260428` lands. | medium | 2026-04-29 | | TD-003 | plugin-scaffold-20260428 | `@pleaseai/eslint-config@0.0.1` is the only published version; source repo shows 0.0.3. Bump pin when later versions publish. | low | 2026-04-29 | | TD-004 | plugin-scaffold-20260428 | CI uses `actions/setup-node@v4` only because eslint shells out to Node. If linting moves to a Bun-native path, drop the Node setup step. | low | 2026-04-29 | @@ -18,3 +17,4 @@ | ID | Source Track | Description | Resolved In | Date | |----|------------|-------------|-------------|------| +| TD-001 | plugin-scaffold-20260428 | `consola@3.4.2` adopted as runtime dep but no call site yet. Removed — zero call sites, and it would have been inlined into the dependency-free skill bundles. | spring-docs-skill | 2026-09-13 | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6dcecae..002f5d2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -3,10 +3,14 @@ > Agent-first architecture document for `@pleaseai/spring` — Claude Code plugin > for Spring ecosystem documentation. -> **Status**: Target architecture. The repository currently contains only -> `README.md`, `LICENSE`, and project metadata; the modules described below -> are the planned implementation. As code lands, this document is updated -> to reflect actual structure (not aspirational design). +> **Status**: Target architecture, **partly superseded**. Detection +> (`scripts/detect.ts`), documentation resolution (`scripts/docs.ts`) and the +> `spring-docs` skill are implemented. They do not install documentation into +> `.claude/skills/spring-*/` and do not annotate the project's `CLAUDE.md`: +> an archive is unpacked once into `~/.cache/pleaseai-spring/docs//` and +> callers are handed the path (see README, "Why not install the docs into the +> project"). The install/resolve/convert pipeline described below is still the +> unrevised earlier design; it is rewritten when the remaining stages land. ## System Overview @@ -38,11 +42,11 @@ Dependencies flow downward only. Lower layers must not import upper layers. ``` ┌──────────────────────────────────────────────────┐ -│ Interface Layer │ Slash commands (commands/*.md) -│ ───────────────── │ Plugin manifest (.claude-plugin/plugin.json) +│ Interface Layer │ Plugin manifest (.claude-plugin/plugin.json) +│ ───────────────── │ ├──────────────────────────────────────────────────┤ │ Skill Layer │ skills/spring-installer/SKILL.md -│ ─────────── │ (Bridges commands → scripts; Claude-invoked) +│ ─────────── │ (Bridges Claude → scripts; Claude-invoked) ├──────────────────────────────────────────────────┤ │ Orchestration Layer │ scripts/install.ts (top-level pipeline) │ ─────────────────── │ @@ -62,13 +66,11 @@ Dependencies flow downward only. Lower layers must not import upper layers. - **Library layer has no I/O**: pure functions for parsing and conversion. All reads/writes happen one layer up. This keeps `scripts/lib/*` trivially unit-testable with fixture inputs. - **Domain layer functions are independently runnable**: `bun run scripts/fetch.ts framework 6.2.1 --output /tmp/...` works without first running detect or resolve. Each stage is a usable CLI on its own. -- **Slash commands hold no logic**: `commands/install.md` just delegates; behavior lives in the skill and scripts. ## Entry Points For understanding **the install pipeline** (most common starting point): -- `commands/install.md` — Slash command entry. Maps `/spring:install` to the installer skill. - `skills/spring-installer/SKILL.md` — Skill description that Claude Code auto-invokes; orchestrates calls to scripts. - `scripts/install.ts` — Top-level pipeline: detect → resolve → acquire → install → annotate `CLAUDE.md`. @@ -92,8 +94,7 @@ For understanding **what gets written to the user's project**: | Module | Purpose | Key Files | Depends On | Depended By | | --------------------------- | ------------------------------------------------------ | ----------------------------------------------- | --------------------------------------- | ---------------------------- | | `.claude-plugin/` | Plugin manifest (Claude Code convention). | `plugin.json` | — | Claude Code runtime | -| `commands/` | Slash command entry points (thin Markdown). | `install.md`, `list.md`, `update.md`, `remove.md`, `add.md` | `skills/spring-installer/` | Claude Code runtime | -| `skills/spring-installer/` | Skill that orchestrates scripts; Claude-invoked. | `SKILL.md` | `scripts/install.ts` | `commands/` | +| `skills/spring-installer/` | Skill that orchestrates scripts; Claude-invoked. | `SKILL.md` | `scripts/install.ts` | Claude Code runtime | | `scripts/` (orchestration) | Pipeline driver; one `.ts` per stage. | `install.ts`, `detect.ts`, `resolve.ts`, `fetch.ts` | `scripts/lib/`, network, filesystem | `skills/spring-installer/` | | `scripts/lib/` | Pure helpers (parsing, conversion rules, schemas). | `antora-rules.ts`, `manifest.ts` | — | `scripts/*.ts` | | `prebuilt/` | Catalog mapping `{component, version}` → release URL. | `catalog.json` | — | `scripts/fetch.ts` | @@ -110,10 +111,13 @@ For understanding **what gets written to the user's project**: These constraints must hold across all changes. Violations are blocking review issues. **Plugin layout follows Claude Code conventions strictly.** -The manifest lives at `.claude-plugin/plugin.json` and is the **only** file in that directory. All other components (`skills/`, `commands/`, `scripts/`) sit at the plugin root. Path references inside skills/scripts use `${CLAUDE_PLUGIN_ROOT}`. *Why*: Claude Code's plugin loader assumes this layout — deviating breaks discovery. +The manifest lives at `.claude-plugin/plugin.json` and is the **only** file in that directory. All other components (`skills/`, `scripts/`) sit at the plugin root. *Why*: Claude Code's plugin loader assumes this layout — deviating breaks discovery. -**Commands hold no logic.** -A `commands/*.md` file is a thin entry point that names a skill or invokes a script. Behavior lives in the skill or in `scripts/`. *Why*: keeps commands inspectable and lets the same logic be reached from scripts/tests without going through the slash-command path. +**The skill directory is self-contained.** +`skills/spring-docs/` carries everything it executes: `SKILL.md` plus the dependency-free bundles under its own `scripts/`. Skill content addresses them through `${CLAUDE_SKILL_DIR}`, never `${CLAUDE_PLUGIN_ROOT}`. *Why*: the skill ships through two channels. As a plugin it gets a plugin root and an automatic dependency install; installed standalone with `npx skills` it gets neither, because only the skill directory is copied. `${CLAUDE_SKILL_DIR}` resolves in both, `${CLAUDE_PLUGIN_ROOT}` is substituted only in plugin skills, and a bare relative path resolves against the user's project — where these scripts do not exist. + +**Bundles are generated, never edited.** +`skills/spring-docs/scripts/*.mjs` is output from `bun run build:skill`; the TypeScript sources under `scripts/` are the only editable form. CI fails on a stale bundle (`bun run build:skill:check`). *Why*: `npx skills` copies straight from the repository, so no build step runs between the source and the installed skill — the artifact has to be committed, and a committed artifact drifts unless something checks it. **Library layer is I/O-free.** Modules under `scripts/lib/` (e.g., `antora-rules.ts`, `manifest.ts`) accept inputs and return outputs — no `fetch`, no `fs`, no `process.env`. *Why*: lets the bulk of the conversion logic be tested with fixture inputs and run safely in any environment. diff --git a/README.md b/README.md index 19b63ba..74e7bc7 100644 --- a/README.md +++ b/README.md @@ -1,425 +1,147 @@ # @pleaseai/spring -> Claude Code plugin for Spring ecosystem documentation. +> Claude Code plugin for version-matched Spring reference documentation. -Detects Spring versions from your build files, downloads matching reference docs as LLM-friendly Markdown, and makes them available to Claude Code as version-aware skills. Works across Spring Framework, Boot, Security, Data, and Cloud. +Answers Spring questions from the documentation of the version your project actually declares, not the newest release. It reads the Spring Boot version out of your build file, resolves it to a published documentation archive, unpacks it once into a shared cache, and points Claude at that directory. [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE) -## What this does +## Status -When you run `/spring:install` in a Spring project, this plugin: +Two scripts and one skill are implemented: build-file detection, documentation resolution, and the `spring-docs` skill that ties them together. There are no slash commands yet — the skill is the interface, and Claude invokes it on its own when a question needs Spring documentation. -1. **Detects** Spring versions from `build.gradle`, `build.gradle.kts`, or `pom.xml` -2. **Resolves** the full ecosystem via the Spring Boot BOM — one declared Boot version pins Framework, Security, Data, and the rest -3. **Downloads** version-matched documentation (prebuilt archive when available, fresh conversion otherwise) -4. **Installs** it as Claude Code skills under `.claude/skills/spring-*/` - -After install, Claude Code automatically loads the right Spring docs whenever you work on Spring code — no manual lookup, no version mismatch, no hallucinated APIs from the wrong major release. - -## Why a separate plugin - -Spring's documentation has characteristics that don't fit generic doc-fetching tools: - -- **Antora-based** — `xref:`, `include::`, attribute substitution, conditional blocks -- **Multi-repository** — Framework, Boot, Security, Data, Cloud each live in their own repo -- **BOM-driven versioning** — your declared Boot version implicitly pins ten other components -- **Large conversion cost** — full Framework reference is ~200 pages, ~60 seconds to convert -- **Build-tool integration** — version detection requires understanding `build.gradle` and `pom.xml` - -Bundling this complexity into a generic doc tool would inflate it for every user, even those not using Spring. Extracting it as a focused plugin keeps the surface area honest and lets Spring expertise live where it belongs. - -## Installation - -``` -/plugin install pleaseai/spring -``` - -Or, for local development: +## Install ```bash -git clone https://github.com/pleaseai/spring ~/.claude/plugins/spring -``` - -Verify it loaded: - -``` -/spring:list -``` - -## Commands - -### `/spring:install` - -Detect Spring versions in the current project and install matching docs. - +npx skills add pleaseai/spring-plugin ``` -/spring:install # auto-detect everything -/spring:install --boot 3.5 # override Boot line, derive the rest -/spring:install framework # install only Spring Framework -``` - -What happens: -1. Reads `build.gradle` / `build.gradle.kts` / `pom.xml` from project root -2. Finds the Spring Boot version (most projects pin via `spring-boot-starter-parent` or the Spring Dependency Management plugin) -3. Fetches the matching `spring-boot-dependencies` BOM from Maven Central -4. Resolves transitive Spring component versions (Framework, Security, Data, etc.) -5. For each component: - - Checks if a prebuilt archive exists in [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) releases - - If yes: downloads and extracts (~3 seconds) - - If no: fetches docs from `docs.spring.io`, converts Antora HTML to Markdown (~30–60 seconds) -6. Installs into `.claude/skills/spring-/` with a generated `SKILL.md` -7. Updates the project's `CLAUDE.md` with version notes +This installs the `spring-docs` skill for whichever agents the +[`skills` CLI](https://github.com/vercel-labs/skills) detects. It copies only the +skill directory, which is why the scripts it runs are committed as +dependency-free bundles inside it. -Idempotent: re-running with the same versions is a no-op. Safe to put in a postinstall hook. +The same directory also loads as a Claude Code plugin. There is no marketplace +entry yet, so that route is the symlink under [Development](#development). -### `/spring:list` +## What it does -Show installed Spring skills and their versions. - -``` -/spring:list ``` +you: "does spring.jpa.open-in-view still default to true?" + ├─ detect.mjs . → build.gradle declares Boot 3.5.16 + ├─ docs.mjs boot 3.5.16 → ~/.cache/pleaseai-spring/docs/boot-3.5.16 + └─ Claude reads _index.md, opens the pages it needs, answers from 3.5.16 ``` -spring-framework 6.2.1 (auto-detected from Boot 3.5.0) -spring-boot 3.5.0 (declared in build.gradle) -spring-security 6.4.0 (auto-detected from Boot 3.5.0) -spring-data-jpa 3.5.0 (auto-detected from Boot 3.5.0) -``` - -### `/spring:update` - -Refresh installed components against the latest patches in their declared minor lines. - -``` -/spring:update # all installed components -/spring:update framework # one component -/spring:update --check # dry run, no changes -``` - -Honors the version line declared at install time. To move across minor or major lines, use `/spring:install` again with a new Boot version. -### `/spring:remove` +Nothing is written into your project. No `.claude/skills/spring-*/` tree, no `CLAUDE.md` block, no `.gitignore` entry — the documentation lives in a cache shared across every project and branch on the machine. -Uninstall one or more components. Removes the skill directory and the corresponding `CLAUDE.md` block. +## Why not install the docs into the project -``` -/spring:remove security -/spring:remove --all -``` - -### `/spring:add` - -Install a single component without auto-detecting from build files. Useful for projects that don't use Boot, or for adding components outside the BOM. - -``` -/spring:add framework@6.2.1 -/spring:add cloud-gateway@2024.0.0 -``` - -## How Claude Code uses installed skills - -After install, your project structure includes: - -``` -.claude/skills/ -├── spring-framework/ -│ ├── SKILL.md ← Auto-loaded by Claude when relevant -│ ├── manifest.json ← Version, source URL, fetch timestamp -│ ├── INDEX.md ← Table of contents -│ └── references/ -│ ├── core/ -│ │ ├── beans.md -│ │ └── ... -│ ├── web/ -│ │ ├── webmvc.md -│ │ └── ... -│ └── ... -├── spring-boot/ -└── spring-security/ -``` - -The generated `SKILL.md` carries a description like: - -```markdown ---- -name: spring-framework-docs -description: Use when answering questions about Spring Framework 6.2.1 - APIs, configuration, or behavior. Covers core IoC, web MVC, web reactive, - data access, transactions, AOP, and testing. Do NOT use for Spring Boot, - Security, or Cloud — use those dedicated skills instead. ---- -``` - -Claude Code's auto-invocation matches this description against the conversation. When you ask a Spring Framework question, the skill loads, Claude consults the references, and answers with version-correct information. - -The plugin also appends a block to your project's `CLAUDE.md`: - -```markdown - -## Spring References (managed by @pleaseai/spring) - -- Spring Framework **6.2.1** — see `.claude/skills/spring-framework/` -- Spring Boot **3.5.0** — see `.claude/skills/spring-boot/` -- Spring Security **6.4.0** — see `.claude/skills/spring-security/` - -When answering Spring questions, consult these references first. -Do NOT mix information across major versions. - -``` - -The `` markers let `/spring:remove` cleanly delete this block without touching anything else in your `CLAUDE.md`. - -## Plugin structure - -``` -pleaseai/spring/ -├── .claude-plugin/ -│ └── plugin.json ← Plugin manifest -├── skills/ ← Skills shipped with the plugin -│ └── spring-installer/ -│ └── SKILL.md ← Implements /spring:install behavior -├── commands/ ← Slash command entry points -│ ├── install.md ← /spring:install -│ ├── list.md ← /spring:list -│ ├── update.md ← /spring:update -│ ├── remove.md ← /spring:remove -│ └── add.md ← /spring:add -├── scripts/ ← Implementation invoked by skills -│ ├── detect.ts ← Build file parsing -│ ├── resolve.ts ← BOM-based version resolution -│ ├── fetch.ts ← Docs download + conversion -│ ├── install.ts ← Skill installation -│ └── lib/ -│ ├── antora-rules.ts ← Antora-specific Turndown rules -│ └── manifest.ts ← .claude/skills/*/manifest.json schema -├── prebuilt/ ← Cached snapshot of spring-docs catalog -│ └── catalog.json ← Mirror of pleaseai/spring-docs/catalog.json (offline fallback) -└── .github/workflows/ - └── ci.yml ← typecheck / lint / test on PRs -``` +An early design wrote each component's Markdown under `.claude/skills/spring-*/` and annotated the project's `CLAUDE.md`. That was dropped: -Archive generation lives in [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs); this plugin only consumes its Releases. +- **One version is 150-250 files** (2.4 MB for Boot 3.5.16, 3.6 MB for 4.1.1). In the project tree that is a permanent diff, a `.gitignore` entry, and a branch-switch hazard. +- **Every project pays again** for the same version. +- **Rewriting someone's `CLAUDE.md`** is a trust cost with no return once the skill can simply name a path. +- **Staleness**: on-disk skills drift when the declared version changes. Resolving per question cannot drift. -Per Claude Code conventions: -- The manifest lives at `.claude-plugin/plugin.json` (only file in that directory) -- All component directories (`skills/`, `commands/`, `scripts/`) live at plugin root -- `${CLAUDE_PLUGIN_ROOT}` is used in any path reference inside skills/scripts +The cache is keyed by release **tag**, not by version, so a corrected archive (`boot-4.1.1+rebuild.1`) lands beside the one it supersedes instead of silently serving stale bytes. -## Version resolution +## Usage -Spring's ecosystem versioning is centralized through Spring Boot's BOM (`spring-boot-dependencies`). Once you pin Boot, the rest follows. - -Example: `build.gradle` with Boot 3.5.0: - -```groovy -plugins { - id 'org.springframework.boot' version '3.5.0' - id 'io.spring.dependency-management' version '1.1.6' -} -``` - -The plugin fetches `spring-boot-dependencies-3.5.0.pom` from Maven Central and reads: - -```xml - - 6.2.1 - 6.4.0 - 2025.0.0 - ... - -``` - -This becomes the source of truth for which doc versions to install. We do not maintain a separate compatibility matrix — the BOM is authoritative. - -For projects without Boot (rare), use `/spring:add` to install components individually with explicit versions. - -### Pre-release and EOL versions - -- **Pre-release** (RC, M1, SNAPSHOT): Not supported. Use the latest GA in your line. -- **EOL versions**: Supported as long as upstream docs are reachable. The plugin emits a warning on install but proceeds. - -## Prebuilt archives - -To keep `/spring:install` fast, pre-converted Markdown archives are maintained in a **separate content repository**: [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs). This plugin downloads matching archives from its GitHub Releases at install time. Splitting content from code keeps the plugin small (~1 MB), lets the conversion pipeline release on its own cadence, and makes the archives reusable by non-Claude-Code tools (Cursor, Continue, RAG indexes, etc.). - -Coverage maintained in `spring-docs`: - -| Component | Version lines maintained | -|---|---| -| spring-framework | Latest two minor lines | -| spring-boot | Latest three minor lines | -| spring-security | Latest two minor lines | -| spring-data-jpa | Latest two minor lines | -| spring-cloud | Latest year line | - -Archives are built nightly from upstream releases. If your project uses a version `spring-docs` doesn't have prebuilt, the plugin falls back to live conversion automatically — slower (~60s) but always works. - -To skip the prebuilt cache and always convert fresh: - -``` -/spring:install --no-prebuilt -``` - -Useful if you're debugging a conversion issue or want to verify a fresh build matches the release. - -## Manual fallback - -If your environment can't reach `github.com` or `docs.spring.io`, you can pre-stage archives: +The skill runs the scripts for you. To use them directly from a clone: ```bash -# Download on a connected machine (tag scheme: -, e.g., framework-6.2.1) -curl -L -o spring-framework-6.2.1.tar.gz \ - https://github.com/pleaseai/spring-docs/releases/download/framework-6.2.1/spring-framework-6.2.1.tar.gz +# Which Boot version does this project declare? +bun run scripts/detect.ts . -# Place in the plugin's offline cache -mkdir -p ~/.cache/pleaseai-spring/archives/ -mv spring-framework-6.2.1.tar.gz ~/.cache/pleaseai-spring/archives/ +# Resolve that version's docs; prints JSON with a `path` +bun run scripts/docs.ts boot 3.5.16 -# Install reads from cache first -/spring:install +# Require a cache hit (offline), or force a re-download +bun run scripts/docs.ts boot 3.5.16 --no-fetch +bun run scripts/docs.ts boot 3.5.16 --refresh ``` -## Configuration - -The plugin reads configuration from `.spring-skill.json` at project root. All fields optional. - ```json { - "components": ["framework", "boot", "security"], - "excludeComponents": ["data-r2dbc", "data-cassandra"], - "boot": "3.5.0", - "skipPrebuilt": false, - "claudeMdMarker": "spring-skill", - "skillsDir": ".claude/skills" + "kind": "ready", + "project": "boot", + "version": "3.5.16", + "tag": "boot-3.5.16", + "path": "/Users/you/.cache/pleaseai-spring/docs/boot-3.5.16", + "index": "/Users/you/.cache/pleaseai-spring/docs/boot-3.5.16/_index.md", + "cached": true } ``` -CLI flags override this file, which overrides auto-detection. +An installed skill runs the committed bundles instead, with `node` and no dependencies: `node /scripts/docs.mjs boot 3.5.16`. -## Eval results +A version that has not been published comes back as `kind: "unavailable"` with the issue tracker in `suggestion`. The skill is instructed not to quietly substitute a different version — answering from the wrong minor is the failure this plugin exists to prevent. -We benchmark this plugin against bare Claude Code on a Spring task suite. Methodology and full results in [`evals/spring/`](evals/spring/). +## How resolution works -| Setup | Pass rate | Wrong-version errors | Avg cost | -|---|---|---|---| -| **`@pleaseai/spring` installed** | **94%** (47/50) | 0 | $1.42 | -| Bare Claude Code | 62% (31/50) | 14 | $2.18 | -| `WebFetch` of `docs.spring.io` per task | 78% (39/50) | 6 | $3.91 | +1. **Catalog lookup** — `catalog.json` on [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) maps `(project, version)` to a release tag. It is a few kilobytes and is fetched every time, because it is the only thing that reports a rebuild having moved a version to a new tag. +2. **Cache check** — if the tag's directory is already unpacked, that path is returned and nothing else is downloaded. +3. **Download and verify** — the `.tar.gz` (0.4-0.5 MB) and its `.sha256` sidecar. A digest mismatch writes nothing and fails loudly. +4. **Unpack** — into a staging directory beside the target, then renamed into place, so an interrupted run never leaves a half-written tree under the name callers read. -The wrong-version errors are particularly stark: without versioned skills, Claude often answers with Spring 5.x patterns or pre-release features that don't exist in the user's actual version. +Each unpacked tree carries the `manifest.json` from its release: upstream repository, ref, commit, converter versions, file count, and a checksum over the content. -## Comparison with related tools +## Coverage -| Tool | Scope | Approach | +| Project | Versions | Source | |---|---|---| -| **`@pleaseai/spring`** | Spring only | Versioned skills, BOM resolution, Antora-aware conversion | -| `@pleaseai/ask` | Generic (npm/github/pypi/pub) | Lazy fetch via `ask src` / `ask docs` | -| Context7 (MCP) | Generic | Live MCP server lookups | -| `WebFetch` | Generic | Per-query HTTP fetch | - -We recommend installing both `@pleaseai/spring` and `@pleaseai/ask`. They complement each other: -- Spring plugin handles Spring's Antora ecosystem and BOM resolution -- ask handles everything else (Vue, React, Bun, your favorite npm package) +| `boot` | Spring Boot `3.3.0`-`3.x`, `4.0.8`+ | [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) releases | -They write to different skill directories and don't conflict. +Not buildable upstream, and therefore absent: Boot 3.2 and older predate the Antora documentation component, and 4.0.0-4.0.7 publish no content archive. Pre-release versions (M, RC, SNAPSHOT) are out of scope. -## Development - -```bash -git clone https://github.com/pleaseai/spring -cd spring -bun install +Spring Boot 3.x trees omit the generated appendix — auto-configuration class listings and configuration-property tables are a Gradle build output upstream never publishes. The prose corpus (reference, how-to, tutorial, specification) is complete. -# Run conversion locally against a specific version -bun run scripts/fetch.ts framework 6.2.1 --output /tmp/spring-framework-6.2.1 +Framework, Security, Data and Cloud are not published yet. When they are, resolving them is the same call with a different project key; BOM-based resolution of one declared Boot version into the whole component matrix belongs to that point, not before it. -# Inspect the result -ls /tmp/spring-framework-6.2.1/ +## Plugin structure -# Test plugin loading in Claude Code -ln -s "$(pwd)" ~/.claude/plugins/spring +``` +.claude-plugin/plugin.json plugin manifest +skills/spring-docs/SKILL.md the skill Claude invokes +skills/spring-docs/scripts/ generated bundles — `bun run build:skill`, do not edit +scripts/detect.ts build-file detection (Gradle Groovy/Kotlin, Maven) +scripts/docs.ts catalog lookup, download, verify, unpack +scripts/build-skill.ts bundles the two entrypoints into the skill directory +scripts/lib/ pure helpers — no I/O +scripts/__tests__/ bun tests ``` -Issues and PRs welcome. See [`CONTRIBUTING.md`](./CONTRIBUTING.md). - -### Local Development +Archive generation is not here. The conversion pipeline (Antora, Asciidoctor, the Markdown converter) lives in [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs); this plugin only consumes its releases. -After cloning, install dev dependencies and run the toolchain: +## Development ```bash -bun install # install dev deps from bun.lock +git clone https://github.com/pleaseai/spring-plugin +cd spring-plugin +bun install + bun run typecheck # tsc --noEmit bun run lint # eslint --max-warnings 0 -bun run lint:fix # eslint --fix (auto-fix style + format) -bun test # Bun test runner -``` - -Linting and formatting are unified through -[`@pleaseai/eslint-config`](https://github.com/pleaseai/code-style/tree/main/packages/eslint-config) -(built on `@antfu/eslint-config`) — no Prettier. A pre-commit hook -(Husky + `lint-staged`) runs `eslint --fix` on staged files; the same checks -run in CI on every PR via `.github/workflows/ci.yml`. +bun test # bun test runner +bun run build:skill # rebuild the committed skill bundles -### Project Layout - -``` -.claude-plugin/plugin.json plugin manifest (only file in this directory) -commands/ slash command entry points (placeholder) -skills/ auto-loaded skills (placeholder) -scripts/ implementation scripts (placeholder) -└── lib/__tests__/ placeholder test confirming bun test wiring -.github/workflows/ci.yml typecheck / lint / test on PRs -.husky/pre-commit lint-staged on commit -.please/ workspace state (specs, plans, knowledge) +# Load it into Claude Code +ln -s "$(pwd)" ~/.claude/plugins/spring ``` -The repository is currently a tooling skeleton — source files (`scripts/*.ts`, -`skills/*/SKILL.md`, `commands/*.md`) land in subsequent feature tracks. - -## Licensing +Linting and formatting are unified through [`@pleaseai/eslint-config`](https://github.com/pleaseai/code-style/tree/main/packages/eslint-config) — no Prettier. Husky + `lint-staged` run `eslint --fix` on staged files, and CI runs the same checks on every PR. -### Plugin code - -Licensed under **Apache-2.0**. See [`LICENSE`](./LICENSE). - -### Generated archives - -The Markdown archives live in [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) and each carries Spring's upstream license (Apache-2.0) in a `NOTICE` file pinned to the exact source commit. We do not relicense documentation content; we only change format. - -If you are a Spring maintainer and have concerns about how documentation is mirrored, please open an issue on [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs/issues). - -## FAQ - -**Why not just use `WebFetch` per question?** -Live fetching is slow, costs more in tokens, and gives Claude unstructured HTML. Pre-installed Markdown skills load instantly with version metadata baked in, and Claude's auto-invocation finds the right section without exploration. - -**Why a Boot-centric design? My project doesn't use Boot.** -Most Spring projects do, and the BOM is the cleanest authoritative source for version resolution. For non-Boot projects, `/spring:add` lets you install components with explicit versions. - -**How do I share installed skills with my team?** -Commit `.claude/skills/spring-*/` to your repo. The skills are plain Markdown — they version-control cleanly. Teammates skip the install step. - -**Does this work offline?** -Yes, after one online install. Subsequent sessions read from `.claude/skills/` only. The "Manual fallback" section covers fully air-gapped setups. - -**What about Spring projects in Kotlin? Or with Gradle Kotlin DSL?** -Both supported. The detector handles `build.gradle.kts` and works with Kotlin/Java/Groovy projects identically. - -**Can I use this without Claude Code?** -The skill files are plain Markdown — any LLM tool that reads `.claude/skills/` or similar conventions can use them. But the slash commands (`/spring:install`) are Claude Code-specific. +## Related projects -**Why does `spring-data-jpa` have its own skill, but not `spring-data-jdbc`?** -We ship skills for components in the default coverage matrix. To install others, use `/spring:add data-jdbc@`. Conversion happens live (no prebuilt) but works the same. +- [`@pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) — the content repository this plugin reads +- [`@pleaseai/ask`](https://github.com/pleaseai/ask) — generic library docs for Claude Code (npm, github, pypi, pub) +- [Spring Boot](https://github.com/spring-projects/spring-boot) — upstream -## Related projects +## Licensing -- [`@pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) — Content repository hosting the pre-converted Markdown archives this plugin downloads -- [`@pleaseai/ask`](https://github.com/pleaseai/ask) — Generic library docs for Claude Code (npm, github, pypi, pub) -- [Spring Framework](https://github.com/spring-projects/spring-framework) — Upstream -- [Spring Boot](https://github.com/spring-projects/spring-boot) — Upstream +Plugin code is Apache-2.0 ([`LICENSE`](./LICENSE)). The documentation archives keep Spring's upstream Apache-2.0 license: every archive ships a `NOTICE` pinned to the source commit, and nothing about the content's meaning is changed. Concerns about the mirroring belong on [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs/issues). --- diff --git a/bun.lock b/bun.lock index cbc6ef6..58b1a3a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "@pleaseai/spring", "dependencies": { - "consola": "3.4.2", "fast-xml-parser": "^5.7.2", }, "devDependencies": { @@ -201,8 +200,6 @@ "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], - "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], diff --git a/commands/.gitkeep b/commands/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/eslint.config.js b/eslint.config.js index 32dfd1e..011b58a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,8 @@ export default [ 'node_modules/**', 'dist/**', 'bun.lock', + // Generated by `bun run build:skill` — regenerate, never hand-edit. + 'skills/spring-docs/scripts/**', 'LICENSE', // Workspace state and external configs (not authored by this plugin) '.please/**', diff --git a/package.json b/package.json index 1314ad5..728b48d 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,13 @@ "url": "git+https://github.com/pleaseai/spring-plugin.git" }, "engines": { - "bun": ">=1.3.0" + "bun": ">=1.4.2" }, "scripts": { "test": "bun test", "test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov", + "build:skill": "bun run scripts/build-skill.ts", + "build:skill:check": "bun run scripts/build-skill.ts --check", "coverage:check": "bun run scripts/coverage-check.ts", "typecheck": "tsc --noEmit", "lint": "eslint --max-warnings 0", @@ -23,7 +25,6 @@ "prepare": "husky" }, "dependencies": { - "consola": "3.4.2", "fast-xml-parser": "^5.7.2" }, "devDependencies": { diff --git a/scripts/__tests__/docs-cache.test.ts b/scripts/__tests__/docs-cache.test.ts new file mode 100644 index 0000000..d3b5bea --- /dev/null +++ b/scripts/__tests__/docs-cache.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from 'bun:test' + +import { + archiveName, + archiveUrl, + checksumUrl, + docsCachePath, + isCatalog, + isSafeSegment, + lookupTag, + parseChecksum, +} from '../lib/docs-cache.ts' + +const CATALOG = { + version: '1', + generated_at: '2026-09-12T00:00:00Z', + projects: { + boot: { + '3.5.16': { tag: 'boot-3.5.16', released_at: '2026-09-12T12:36:20Z' }, + '4.1.1': { tag: 'boot-4.1.1+rebuild.1', released_at: '2026-09-12T00:00:00Z' }, + }, + }, +} + +describe('lookupTag', () => { + test('returns the tag the catalog records, rebuild suffix included', () => { + expect(lookupTag(CATALOG, 'boot', '4.1.1')).toEqual({ + kind: 'found', + tag: 'boot-4.1.1+rebuild.1', + releasedAt: '2026-09-12T00:00:00Z', + }) + }) + + test('refuses a catalog schema it does not understand', () => { + expect(lookupTag({ ...CATALOG, version: '2' }, 'boot', '3.5.16')).toEqual({ kind: 'schema', found: '2' }) + }) + + test('names the known projects when the project is absent', () => { + expect(lookupTag(CATALOG, 'framework', '6.2.0')).toEqual({ + kind: 'unknown-project', + project: 'framework', + known: ['boot'], + }) + }) + + test('reports an unpublished version separately from an unknown project', () => { + const result = lookupTag(CATALOG, 'boot', '3.4.0') + expect(result.kind).toBe('unknown-version') + }) +}) + +describe('asset naming', () => { + test('an archive is named for the version, not for the tag that published it', () => { + expect(archiveName('boot', '4.1.1')).toBe('boot-4.1.1.tar.gz') + expect(archiveUrl('boot-4.1.1+rebuild.1', 'boot', '4.1.1')) + .toBe('https://github.com/pleaseai/spring-docs/releases/download/boot-4.1.1+rebuild.1/boot-4.1.1.tar.gz') + expect(checksumUrl('boot-4.1.1', 'boot', '4.1.1')).toEndWith('/boot-4.1.1.tar.gz.sha256') + }) + + test('the cache is keyed by tag, so a rebuild lands beside what it supersedes', () => { + expect(docsCachePath('/home/u', 'boot-4.1.1')) + .not + .toBe(docsCachePath('/home/u', 'boot-4.1.1+rebuild.1')) + expect(docsCachePath('/home/u', 'boot-4.1.1')).toBe('/home/u/.cache/pleaseai-spring/docs/boot-4.1.1') + }) +}) + +describe('parseChecksum', () => { + const digest = 'a'.repeat(64) + + test('reads the digest from a sha256sum line', () => { + expect(parseChecksum(`${digest} boot-4.1.1.tar.gz\n`, 'boot-4.1.1.tar.gz')).toBe(digest) + }) + + test('accepts the binary-mode marker', () => { + expect(parseChecksum(`${digest} *boot-4.1.1.tar.gz`, 'boot-4.1.1.tar.gz')).toBe(digest) + }) + + test('rejects a checksum that names a different archive', () => { + expect(parseChecksum(`${digest} boot-4.0.8.tar.gz`, 'boot-4.1.1.tar.gz')).toBeUndefined() + }) + + test('rejects malformed input', () => { + expect(parseChecksum('', 'boot-4.1.1.tar.gz')).toBeUndefined() + expect(parseChecksum('not-a-digest boot-4.1.1.tar.gz', 'boot-4.1.1.tar.gz')).toBeUndefined() + }) +}) + +describe('isSafeSegment', () => { + test('accepts the project, version and rebuild-tag spellings the catalog uses', () => { + expect(isSafeSegment('boot')).toBe(true) + expect(isSafeSegment('3.5.16')).toBe(true) + expect(isSafeSegment('boot-4.1.1+rebuild.1')).toBe(true) + }) + + test('rejects the traversal segments a charset test alone would admit', () => { + // Both are spelled entirely in allowed characters, and joining either one + // climbs out of the cache directory. + expect(isSafeSegment('..')).toBe(false) + expect(isSafeSegment('.')).toBe(false) + }) + + test('rejects separators and the empty string', () => { + expect(isSafeSegment('../../etc')).toBe(false) + expect(isSafeSegment('a/b')).toBe(false) + expect(isSafeSegment('a\\b')).toBe(false) + expect(isSafeSegment('')).toBe(false) + }) +}) + +describe('lookupTag — unpublished entries', () => { + test('reports a reserved tag with no archive as unpublished, not found', () => { + const catalog = { + version: '1', + generated_at: null, + projects: { boot: { '9.9.9': { tag: 'boot-9.9.9', released_at: null } } }, + } + // Downloading from a reserved-but-empty tag 404s, which reads as an + // unreachable network rather than as the "not built yet" it is. + expect(lookupTag(catalog, 'boot', '9.9.9')).toEqual({ + kind: 'unpublished', + project: 'boot', + version: '9.9.9', + tag: 'boot-9.9.9', + }) + }) +}) + +describe('isCatalog', () => { + test('accepts a well-formed catalog', () => { + expect(isCatalog(CATALOG)).toBe(true) + }) + + test('rejects valid JSON that lookupTag would throw on', () => { + expect(isCatalog(null)).toBe(false) + expect(isCatalog({ version: '1' })).toBe(false) + expect(isCatalog({ version: 1, projects: {} })).toBe(false) + }) + + test('rejects an entry with no tag', () => { + expect(isCatalog({ version: '1', projects: { boot: { '3.5.16': { released_at: null } } } })).toBe(false) + }) + + test('rejects an array where a keyed map is required', () => { + expect(isCatalog({ version: '1', projects: [] })).toBe(false) + expect(isCatalog({ version: '1', projects: { boot: [] } })).toBe(false) + }) + + test('rejects an entry whose released_at is neither a string nor null', () => { + const entry = (released_at: unknown): unknown => + ({ version: '1', projects: { boot: { '3.5.16': { tag: 'boot-3.5.16', released_at } } } }) + expect(isCatalog(entry(null))).toBe(true) + expect(isCatalog(entry('2026-09-12T00:00:00Z'))).toBe(true) + // Absent, not null: `lookupTag` would hand callers `undefined` behind a + // `string | null` type. + expect(isCatalog({ version: '1', projects: { boot: { '3.5.16': { tag: 'boot-3.5.16' } } } })).toBe(false) + }) +}) diff --git a/scripts/__tests__/docs.test.ts b/scripts/__tests__/docs.test.ts new file mode 100644 index 0000000..d28e9b0 --- /dev/null +++ b/scripts/__tests__/docs.test.ts @@ -0,0 +1,437 @@ +import type { Fetcher } from '../docs.ts' +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { resolveDocs } from '../docs.ts' +import { archiveName, archiveUrl, CATALOG_URL, checksumUrl, DOCS_CACHE_SUBDIR, docsCachePath } from '../lib/docs-cache.ts' + +const PROJECT = 'boot' +const VERSION = '4.1.1' +const TAG = 'boot-4.1.1' + +function catalogJson(tag: string): string { + return JSON.stringify({ + version: '1', + generated_at: '2026-09-12T00:00:00Z', + projects: { boot: { [VERSION]: { tag, released_at: '2026-09-12T00:00:00Z' } } }, + }) +} + +/** Build a real `-/` archive, the shape the docs repo ships. */ +function buildArchive(dir: string, topLevel: string, index = '# Table of contents\n'): Buffer { + const tree = join(dir, topLevel) + mkdirSync(join(tree, 'how-to'), { recursive: true }) + writeFileSync(join(tree, '_index.md'), index) + writeFileSync(join(tree, 'how-to', 'index.md'), '# How-to\n') + const archivePath = join(dir, `${topLevel}.tar.gz`) + const result = Bun.spawnSync(['tar', '-czf', archivePath, '-C', dir, topLevel]) + if (result.exitCode !== 0) + throw new Error(`tar failed: ${result.stderr.toString()}`) + return Buffer.from(readFileSync(archivePath)) +} + +function respond(body: string | Buffer, ok = true, status = 200): Awaited> { + return { + ok, + status, + text: async () => (typeof body === 'string' ? body : body.toString()), + arrayBuffer: async () => { + // Sliced to the view, not handed the whole backing store: a Buffer sits + // in a pooled slab far larger than its payload, and `.buffer` would make + // the caller hash the slab instead of the archive. + const buf = typeof body === 'string' ? Buffer.from(body) : body + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer + }, + } +} + +describe('resolveDocs', () => { + let cacheHome: string + let fixtures: string + + beforeEach(() => { + cacheHome = mkdtempSync(join(tmpdir(), 'spring-docs-home-')) + fixtures = mkdtempSync(join(tmpdir(), 'spring-docs-fixtures-')) + }) + + afterEach(() => { + rmSync(cacheHome, { recursive: true, force: true }) + rmSync(fixtures, { recursive: true, force: true }) + }) + + test('downloads, verifies and unpacks a published version', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`) + const digest = createHash('sha256').update(archive).digest('hex') + const requested: string[] = [] + const fetchImpl: Fetcher = async (url) => { + requested.push(url) + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + if (url === archiveUrl(TAG, PROJECT, VERSION)) + return respond(archive) + throw new Error(`unexpected url ${url}`) + } + + const result = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(result.kind).toBe('ready') + if (result.kind !== 'ready') + return + expect(result.tag).toBe(TAG) + expect(result.cached).toBe(false) + expect(result.path).toBe(docsCachePath(cacheHome, TAG)) + // The tree is unpacked without its `-/` wrapper, so the + // path handed to callers is the documentation root itself. + expect(readFileSync(result.index, 'utf8')).toContain('Table of contents') + expect(existsSync(join(result.path, 'how-to', 'index.md'))).toBe(true) + expect(requested).toContain(archiveUrl(TAG, PROJECT, VERSION)) + }) + + test('serves a cached tree without downloading the archive again', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`) + const digest = createHash('sha256').update(archive).digest('hex') + const urls: string[] = [] + const fetchImpl: Fetcher = async (url) => { + urls.push(url) + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + if (url === archiveUrl(TAG, PROJECT, VERSION)) + return respond(archive) + throw new Error(`unexpected url ${url}`) + } + + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + urls.length = 0 + const second = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(second.kind === 'ready' && second.cached).toBe(true) + // The catalog is still consulted — it is what reports a rebuild — but the + // archive and its checksum are not fetched again. + expect(urls).toEqual([CATALOG_URL]) + }) + + test('follows the catalog to a rebuild tag instead of reusing the cached tree', async () => { + const first = buildArchive(fixtures, `${PROJECT}-${VERSION}`) + const firstDigest = createHash('sha256').update(first).digest('hex') + const rebuiltDir = mkdtempSync(join(tmpdir(), 'spring-docs-rebuild-')) + const rebuiltArchive = buildArchive(rebuiltDir, `${PROJECT}-${VERSION}`, '# Corrected\n') + const rebuiltDigest = createHash('sha256').update(rebuiltArchive).digest('hex') + + const rebuildTag = `${TAG}+rebuild.1` + let tag = TAG + let digest = firstDigest + let archive = first + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(tag)) + if (url === checksumUrl(tag, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + if (url === archiveUrl(tag, PROJECT, VERSION)) + return respond(archive) + throw new Error(`unexpected url ${url}`) + } + + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + tag = rebuildTag + digest = rebuiltDigest + archive = rebuiltArchive + const second = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(second.kind === 'ready' && second.tag).toBe(rebuildTag) + if (second.kind === 'ready') + expect(readFileSync(second.index, 'utf8')).toContain('Corrected') + rmSync(rebuiltDir, { recursive: true, force: true }) + }) + + test('writes nothing when the archive does not match its checksum', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`) + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${'0'.repeat(64)} ${archiveName(PROJECT, VERSION)}\n`) + if (url === archiveUrl(TAG, PROJECT, VERSION)) + return respond(archive) + throw new Error(`unexpected url ${url}`) + } + + const result = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(result.kind).toBe('unavailable') + if (result.kind === 'unavailable') + expect(result.reason).toContain('checksum mismatch') + expect(existsSync(docsCachePath(cacheHome, TAG))).toBe(false) + // Not even a staging directory survives a rejected download. + const docsRoot = join(cacheHome, DOCS_CACHE_SUBDIR) + expect(existsSync(docsRoot) ? readdirSync(docsRoot) : []).toEqual([]) + }) + + test('names the issue tracker when the catalog has no such version', async () => { + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + throw new Error(`unexpected url ${url}`) + } + + const result = await resolveDocs({ project: PROJECT, version: '3.0.0', cacheHome, fetchImpl }) + + expect(result.kind).toBe('unavailable') + if (result.kind === 'unavailable') { + expect(result.reason).toContain('has not published boot 3.0.0') + expect(result.suggestion).toContain('issues') + } + }) + + test('--no-fetch serves a previous resolution and never calls the network', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`) + const digest = createHash('sha256').update(archive).digest('hex') + const online: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + return respond(archive) + } + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl: online }) + + const offline: Fetcher = async () => { + throw new Error('network used') + } + const cached = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl: offline, noFetch: true }) + const missing = await resolveDocs({ project: PROJECT, version: '3.5.16', cacheHome, fetchImpl: offline, noFetch: true }) + + expect(cached.kind === 'ready' && cached.cached).toBe(true) + expect(missing.kind).toBe('unavailable') + if (missing.kind === 'unavailable') + expect(missing.suggestion).toContain('--no-fetch') + }) + + test('refuses a project or version that would escape the cache directory', async () => { + const offline: Fetcher = async () => { + throw new Error('network used') + } + const result = await resolveDocs({ project: PROJECT, version: '../../../../tmp/pwned', cacheHome, fetchImpl: offline }) + + expect(result.kind).toBe('unavailable') + if (result.kind === 'unavailable') + expect(result.reason).toContain('may contain only') + }) + + test('ignores a pointer file that names a tag outside the cache', async () => { + const pointer = `${docsCachePath(cacheHome, `${PROJECT}-${VERSION}`)}.tag` + mkdirSync(join(pointer, '..'), { recursive: true }) + writeFileSync(pointer, '../../../../tmp\n') + const offline: Fetcher = async () => { + throw new Error('network used') + } + const result = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl: offline, noFetch: true }) + + expect(result.kind).toBe('unavailable') + }) + + test('refuses a catalog tag that would escape the cache directory', async () => { + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson('../../../../tmp/pwned')) + throw new Error('network used') + } + const result = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(result.kind).toBe('unavailable') + if (result.kind === 'unavailable') + expect(result.reason).toContain('unusable tag') + }) + + test('reports a catalog whose shape it cannot read instead of throwing', async () => { + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond('{"version":"1"}') + throw new Error('network used') + } + const result = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(result.kind).toBe('unavailable') + if (result.kind === 'unavailable') + expect(result.reason).toContain('expected shape') + }) + + test('refuses an archive that carries no table of contents', async () => { + // Built without `_index.md`, with the checksum kept honest against it. + rmSync(join(fixtures, `${PROJECT}-${VERSION}`), { recursive: true, force: true }) + const tree = join(fixtures, `${PROJECT}-${VERSION}`) + mkdirSync(tree, { recursive: true }) + writeFileSync(join(tree, 'how-to.md'), '# How-to\n') + const indexless = Bun.spawnSync(['tar', '-czf', join(fixtures, 'indexless.tar.gz'), '-C', fixtures, `${PROJECT}-${VERSION}`]) + if (indexless.exitCode !== 0) + throw new Error('tar failed') + const bytes = Buffer.from(readFileSync(join(fixtures, 'indexless.tar.gz'))) + const digest = createHash('sha256').update(bytes).digest('hex') + expect(bytes.length).toBeGreaterThan(0) + + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + return respond(bytes) + } + const result = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(result.kind).toBe('unavailable') + if (result.kind === 'unavailable') + expect(result.reason).toContain('_index.md') + expect(existsSync(docsCachePath(cacheHome, TAG))).toBe(false) + }) + + test('re-downloads over a cached tree that lost its table of contents', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`) + const digest = createHash('sha256').update(archive).digest('hex') + let archiveRequests = 0 + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + archiveRequests += 1 + return respond(archive) + } + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + // A tree left incomplete by an older client is not a cache hit. + rmSync(join(docsCachePath(cacheHome, TAG), '_index.md')) + + const repaired = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(archiveRequests).toBe(2) + expect(repaired.kind === 'ready' && repaired.cached).toBe(false) + expect(existsSync(join(docsCachePath(cacheHome, TAG), '_index.md'))).toBe(true) + expect(readdirSync(cacheHome).length).toBeGreaterThan(0) + }) + + test('replaces an existing tree without leaving the cache path missing', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`, '# First\n') + const digest = createHash('sha256').update(archive).digest('hex') + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + return respond(archive) + } + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + // --refresh publishes over a populated directory; renameSync refuses one + // outright, so this is the case the swap exists for. + const refreshed = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl, refresh: true }) + + expect(refreshed.kind).toBe('ready') + expect(readFileSync(join(docsCachePath(cacheHome, TAG), '_index.md'), 'utf8')).toBe('# First\n') + // No staging or displaced directory survives the publication. + const leftovers = readdirSync(join(cacheHome, '.cache/pleaseai-spring/docs')).filter(n => n.includes('.staging-') || n.includes('.replaced-')) + expect(leftovers).toEqual([]) + }) + + test('reclaims debris a killed run left behind, but not a run in flight', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`, '# First\n') + const digest = createHash('sha256').update(archive).digest('hex') + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + return respond(archive) + } + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + // A process killed between the two renames leaves its displaced tree named + // after the target and never comes back for it; a live run's staging + // directory is named the same way and still has an owner. + const target = docsCachePath(cacheHome, TAG) + const abandoned = `${target}.replaced-abandoned` + const inFlight = `${target}.staging-live` + mkdirSync(abandoned, { recursive: true }) + mkdirSync(inFlight, { recursive: true }) + const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000) + utimesSync(abandoned, longAgo, longAgo) + + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl, refresh: true }) + + expect(existsSync(abandoned)).toBe(false) + expect(existsSync(inFlight)).toBe(true) + }) + + test('reclaims debris on a cache hit, which never reaches a download', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`, '# First\n') + const digest = createHash('sha256').update(archive).digest('hex') + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + return respond(archive) + } + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + // A refresh that died while the previous tree stayed usable: every later + // run is a cache hit, so a sweep that only ran while unpacking would never + // reclaim this. + const abandoned = `${docsCachePath(cacheHome, TAG)}.replaced-abandoned` + mkdirSync(abandoned, { recursive: true }) + const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000) + utimesSync(abandoned, longAgo, longAgo) + + const hit = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(hit.kind === 'ready' && hit.cached).toBe(true) + expect(existsSync(abandoned)).toBe(false) + }) + + test('reports unavailable rather than throwing when the cache path is unusable', async () => { + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + return respond('', false, 404) + } + // A file where the cache directory belongs: `readdirSync` throws ENOTDIR, + // and the sweep runs before the function has produced any result at all. + mkdirSync(join(cacheHome, DOCS_CACHE_SUBDIR, '..'), { recursive: true }) + writeFileSync(join(cacheHome, DOCS_CACHE_SUBDIR), 'not a directory\n') + + const result = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(result.kind).toBe('unavailable') + }) + + test('does not report a tree ready when its index is not a regular file', async () => { + const archive = buildArchive(fixtures, `${PROJECT}-${VERSION}`, '# First\n') + const digest = createHash('sha256').update(archive).digest('hex') + let archiveRequests = 0 + const fetchImpl: Fetcher = async (url) => { + if (url === CATALOG_URL) + return respond(catalogJson(TAG)) + if (url === checksumUrl(TAG, PROJECT, VERSION)) + return respond(`${digest} ${archiveName(PROJECT, VERSION)}\n`) + archiveRequests += 1 + return respond(archive) + } + await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + // A directory by that name exists just as much as a file does, and a tree + // published on that answer is served as ready while nothing can read it. + const index = join(docsCachePath(cacheHome, TAG), '_index.md') + rmSync(index) + mkdirSync(index) + + const repaired = await resolveDocs({ project: PROJECT, version: VERSION, cacheHome, fetchImpl }) + + expect(archiveRequests).toBe(2) + expect(repaired.kind === 'ready' && repaired.cached).toBe(false) + expect(readFileSync(index, 'utf8')).toBe('# First\n') + }) +}) diff --git a/scripts/build-skill.ts b/scripts/build-skill.ts new file mode 100644 index 0000000..e3c2f76 --- /dev/null +++ b/scripts/build-skill.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env bun +/** + * Bundles the domain scripts into `skills/spring-docs/scripts/` — Build step. + * + * The skill ships through two channels with different guarantees. As a Claude + * Code plugin it gets `${CLAUDE_PLUGIN_ROOT}` and an automatic + * `bun install --frozen-lockfile` of the root `package.json`. Installed + * standalone with `npx skills`, it gets neither: only the skill directory is + * copied, so nothing above it exists and no dependency install runs. + * + * Bundling to dependency-free files inside the skill directory is what makes + * the second channel work. The output is committed because `npx skills` copies + * straight from the repository — no build step runs between the two. + * + * Usage: + * bun run build:skill # write the bundles + * bun run build:skill --check # fail if the committed bundles are stale + */ + +import { existsSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import process from 'node:process' + +const ENTRYPOINTS = ['scripts/docs.ts', 'scripts/detect.ts'] +const OUTDIR = 'skills/spring-docs/scripts' +/** + * Extension of the bundles. + * + * `.mjs`, not `.js`: the output is ESM and the skill directory carries no + * `package.json` to declare that, so a `.js` bundle is a CommonJS file to every + * Node that does not detect module syntax on its own — unflagged only since + * 22.7. There it dies on the first `import` before resolving any docs, and the + * standalone install channel is exactly where no `package.json` can be added. + */ +const EXT = '.mjs' +/** The entrypoint's own `#!/usr/bin/env bun` line, replaced by the node one. */ +const SHEBANG = /^#![^\n]*\n/ + +/** Shebang plus a do-not-edit banner, replacing the entrypoint's own shebang. */ +function header(entrypoint: string): string { + return [ + '#!/usr/bin/env node', + `// Generated by \`bun run build:skill\` from ${entrypoint} — do not edit.`, + '// Regenerate and commit after changing anything under scripts/.', + '', + ].join('\n') +} + +/** Bundle one entrypoint and return its final contents. */ +async function bundle(entrypoint: string): Promise { + const built = await Bun.build({ + entrypoints: [entrypoint], + target: 'node', + // The bundle runs from the user's skill directory, where no `node_modules` + // exists, so every non-builtin import has to be inlined. + packages: 'bundle', + define: { + // Bun lowers `import.meta.main` to a `__require` comparison that only + // resolves when some dependency happens to pull in the CJS interop + // helper, so under plain node the guard throws ReferenceError in one + // bundle and works by accident in another. A bundle is always run as the + // entrypoint, so the guard is constant here. + 'import.meta.main': 'true', + }, + }) + if (!built.success) + throw new AggregateError(built.logs, `bundling ${entrypoint} failed`) + + const [artifact] = built.outputs + if (!artifact) + throw new Error(`bundling ${entrypoint} produced no output`) + + const code = await artifact.text() + return header(entrypoint) + code.replace(SHEBANG, '') +} + +async function main(argv: string[]): Promise { + const check = argv.includes('--check') + const stale: string[] = [] + + for (const entrypoint of ENTRYPOINTS) { + const outfile = join(OUTDIR, `${basename(entrypoint, '.ts')}${EXT}`) + const contents = await bundle(entrypoint) + + if (check) { + const current = existsSync(outfile) ? readFileSync(outfile, 'utf8') : '' + if (current !== contents) + stale.push(outfile) + continue + } + + await Bun.write(outfile, contents) + process.stdout.write(`${outfile} ${(contents.length / 1024).toFixed(1)} KB\n`) + } + + if (stale.length > 0) { + process.stderr.write( + `Committed bundles are stale: ${stale.join(', ')}\n` + + 'Run `bun run build:skill` and commit the result.\n', + ) + return 1 + } + + return 0 +} + +if (import.meta.main) { + const code = await main(process.argv.slice(2)) + process.exit(code) +} diff --git a/scripts/docs.ts b/scripts/docs.ts new file mode 100644 index 0000000..bb13c77 --- /dev/null +++ b/scripts/docs.ts @@ -0,0 +1,502 @@ +#!/usr/bin/env bun +/** + * Documentation resolution — Domain Layer (orchestrator + CLI). + * + * Turns a `(project, version)` pair into a filesystem path holding the + * converted Spring documentation for exactly that version, downloading the + * published archive from `pleaseai/spring-docs` on a cache miss. + * + * Nothing is written into the user's project. The docs live in a shared cache + * and callers are handed a path, so a project carries no documentation files, + * `CLAUDE.md` is never rewritten, and two projects on the same Spring version + * share one copy. The pure helpers live in `scripts/lib/docs-cache.ts`; this + * module owns the network and filesystem boundary. + * + * Usage: + * node scripts/docs.ts boot 4.1.1 [--refresh] [--no-fetch] + * + * Exit codes: + * 0 — docs are on disk; `path` in the JSON output says where + * 1 — that version is not published, or it could not be fetched + * 2 — bad arguments, or an unexpected internal error + */ + +import type { Catalog } from './lib/docs-cache.ts' +import { Buffer } from 'node:buffer' +import { spawnSync } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import process from 'node:process' +import { + archiveName, + archiveUrl, + CATALOG_URL, + checksumUrl, + DOCS_REPO, + docsCachePath, + isCatalog, + isSafeSegment, + lookupTag, + parseChecksum, +} from './lib/docs-cache.ts' + +/** Test-only override for the cache home directory, as in `detect.ts`. */ +const CACHE_HOME_ENV_OVERRIDE = 'PLEASEAI_SPRING_CACHE_HOME' + +/** The slice of `fetch` this module uses — narrow enough for a test to supply. */ +export type Fetcher = (url: string) => Promise<{ + readonly ok: boolean + readonly status: number + text: () => Promise + arrayBuffer: () => Promise +}> + +export interface ResolveOptions { + project: string + version: string + /** Directory the `.cache/pleaseai-spring/docs` tree hangs off. */ + cacheHome?: string + fetchImpl?: Fetcher + /** Re-download even when the tree is already cached. */ + refresh?: boolean + /** Never touch the network: serve a previous resolution or fail. */ + noFetch?: boolean +} + +export interface ReadyResult { + kind: 'ready' + project: string + version: string + tag: string + /** Absolute path of the unpacked documentation tree. */ + path: string + /** Table of contents inside {@link path}. */ + index: string + /** True when this run downloaded nothing. */ + cached: boolean +} + +export interface UnavailableResult { + kind: 'unavailable' + project: string + version: string + reason: string + suggestion?: string +} + +export type ResolveResult = ReadyResult | UnavailableResult + +function cacheHomeOf(explicit: string | undefined): string { + return explicit ?? process.env[CACHE_HOME_ENV_OVERRIDE] ?? homedir() +} + +/** + * Records which tag a version resolved to, so `--no-fetch` can find the tree + * without asking the catalog again. A rebuild moves the version to a new tag, + * so the pointer is rewritten on every successful online resolution. + */ +function pointerPath(cacheHome: string, project: string, version: string): string { + return `${docsCachePath(cacheHome, `${project}-${version}`)}.tag` +} + +function readPointer(cacheHome: string, project: string, version: string): string | undefined { + const path = pointerPath(cacheHome, project, version) + if (!existsSync(path)) + return undefined + const tag = readFileSync(path, 'utf8').trim() + // A pointer names the directory this resolves to, so a corrupted or tampered + // one must not be able to point outside the cache. + if (!isSafeSegment(tag)) + return undefined + return tag +} + +function writePointer(cacheHome: string, project: string, version: string, tag: string): void { + const path = pointerPath(cacheHome, project, version) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${tag}\n`) +} + +/** The table of contents every published tree carries; its absence means the tree is unusable. */ +const INDEX_FILE = '_index.md' + +function ready( + project: string, + version: string, + tag: string, + path: string, + cached: boolean, +): ReadyResult { + return { kind: 'ready', project, version, tag, path, index: join(path, INDEX_FILE), cached } +} + +function unavailable( + project: string, + version: string, + reason: string, + suggestion?: string, +): UnavailableResult { + return suggestion === undefined + ? { kind: 'unavailable', project, version, reason } + : { kind: 'unavailable', project, version, reason, suggestion } +} + +/** Fetch one URL as text, turning any transport failure into a message. */ +async function fetchText(fetchImpl: Fetcher, url: string): Promise { + try { + const response = await fetchImpl(url) + if (!response.ok) + return { error: `GET ${url} → ${response.status}` } + return await response.text() + } + catch (err) { + return { error: `GET ${url} failed: ${err instanceof Error ? err.message : String(err)}` } + } +} + +/** True when `path` holds a documentation tree a caller can actually read from. */ +function isUsableTree(path: string): boolean { + try { + // A regular file, not merely an entry: `existsSync` is equally true of a + // directory named `_index.md`, and a tree published on that answer is + // served as ready forever while no caller can read the index out of it. + return statSync(join(path, INDEX_FILE)).isFile() + } + catch { + return false + } +} + +/** + * Move `extracted` into `target`, replacing whatever is there. + * + * Swaps rather than clearing first. `renameSync` refuses a non-empty target + * directory outright, so the old remove-then-rename lost a concurrent race with + * ENOTEMPTY after a good download; and clearing first left the shared path + * missing for as long as the delete took. Two renames still leave a window, but + * a metadata-only one rather than a whole-tree delete. + */ +function publish(extracted: string, target: string): void { + const displaced = existsSync(target) ? `${target}.replaced-${randomUUID()}` : undefined + if (displaced !== undefined) + renameSync(target, displaced) + try { + renameSync(extracted, target) + } + catch (err) { + // Never end emptier than we started: put the previous tree back. Unless a + // concurrent publisher already refilled the path — then its tree is the one + // callers read, and ours is debris rather than a restore candidate. + if (displaced !== undefined) { + if (existsSync(target)) + discard(displaced) + else + renameSync(displaced, target) + } + throw err + } + // The new tree is published and readable from here on, so failing to delete + // the one it replaced is leftover debris, not a failed download. + if (displaced !== undefined) + discard(displaced) +} + +/** Delete a directory nothing reads from any more, without failing the caller. */ +function discard(path: string): void { + try { + rmSync(path, { recursive: true, force: true }) + } + catch { + // Left for `sweepLeftovers` on a later run. + } +} + +/** How long a staging or displaced directory may sit before it counts as debris. */ +const LEFTOVER_TTL_MS = 60 * 60 * 1000 + +/** + * Delete the staging and displaced directories a killed run left behind. + * + * Both are named after `target` and both are removed on every path that + * completes, so whatever is still here belongs either to a run in flight or to + * one that died mid-publication. Age separates them: downloading and extracting + * an archive takes seconds, so an hour-old directory has no owner left to + * break. Without this a crash leaks one whole documentation tree per occurrence + * and nothing ever reclaims it. + */ +function sweepLeftovers(target: string): void { + const parent = dirname(target) + const prefix = basename(target) + const cutoff = Date.now() - LEFTOVER_TTL_MS + let entries: string[] + try { + entries = readdirSync(parent) + } + catch { + // The cache directory does not exist yet on a first run, and it can also + // be a file, be unreadable, or vanish under us. None of that is a reason + // to reject a resolution that has its own answer for a broken cache — and + // an `existsSync` guard would still lose the race to a concurrent delete. + return + } + for (const name of entries) { + if (!name.startsWith(`${prefix}.staging-`) && !name.startsWith(`${prefix}.replaced-`)) + continue + const path = join(parent, name) + try { + if (statSync(path).mtimeMs < cutoff) + rmSync(path, { recursive: true, force: true }) + } + catch { + // Reclaiming disk is never worth failing a download over. + } + } +} + +/** Unpack `archive` and move the single top-level directory it holds to `target`. */ +function unpack(archive: Buffer, project: string, version: string, target: string): void { + mkdirSync(dirname(target), { recursive: true }) + // Staged next to the target so the rename below stays on one filesystem, and + // so a crash mid-extraction never leaves a half-written tree under the name + // callers read from. + const staging = mkdtempSync(`${target}.staging-`) + try { + const archivePath = join(staging, archiveName(project, version)) + writeFileSync(archivePath, archive) + const result = spawnSync('tar', ['-xzf', archivePath, '-C', staging], { encoding: 'utf8' }) + if (result.error) + throw new Error(`could not run tar: ${result.error.message}`) + if (result.status !== 0) + throw new Error(`tar exited ${result.status}: ${(result.stderr ?? '').trim()}`) + + // Every archive entry sits under one `-/` directory, so + // extraction never spills — that is the docs repo's packaging contract. + const extracted = join(staging, `${project}-${version}`) + if (!existsSync(extracted)) + throw new Error(`archive does not contain ${project}-${version}/`) + // Checked before publication, not after: a correctly checksummed but + // mispackaged archive would otherwise be cached as ready with an index + // path that does not resolve. + if (!isUsableTree(extracted)) + throw new Error(`archive does not contain ${project}-${version}/${INDEX_FILE}`) + + publish(extracted, target) + } + finally { + rmSync(staging, { recursive: true, force: true }) + } +} + +/** + * Resolve `(project, version)` to an unpacked documentation tree on disk. + * + * Never throws for a recognized failure — an unpublished version, an + * unreachable network and a corrupt download all come back as + * {@link UnavailableResult}. + */ +export async function resolveDocs(options: ResolveOptions): Promise { + const { project, version, refresh = false, noFetch = false } = options + const fetchImpl = options.fetchImpl ?? ((url: string) => fetch(url)) + const cacheHome = cacheHomeOf(options.cacheHome) + + // Both are joined into cache paths and into the archive URL, so they are + // checked here, at the boundary, rather than at each use. + if (!isSafeSegment(project) || !isSafeSegment(version)) { + return unavailable( + project, + version, + 'project and version may contain only letters, digits, dot, plus, hyphen and underscore', + ) + } + + if (noFetch) { + const tag = readPointer(cacheHome, project, version) + const path = tag === undefined ? undefined : docsCachePath(cacheHome, tag) + if (tag === undefined || path === undefined || !isUsableTree(path)) { + return unavailable( + project, + version, + `${project} ${version} is not in the local cache`, + 'drop --no-fetch to download it', + ) + } + return ready(project, version, tag, path, true) + } + + // The catalog is consulted even on a cache hit: it is a few kilobytes, and it + // is the only thing that reports a rebuild having moved this version to a new + // tag. Only the archive download — the expensive half — is skipped. + const catalogText = await fetchText(fetchImpl, CATALOG_URL) + if (typeof catalogText !== 'string') + return unavailable(project, version, catalogText.error, 'check network access to raw.githubusercontent.com') + + let parsed: unknown + try { + parsed = JSON.parse(catalogText) + } + catch (err) { + return unavailable(project, version, `catalog.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`) + } + if (!isCatalog(parsed)) + return unavailable(project, version, 'catalog.json does not have the expected shape', 'update the plugin') + const catalog: Catalog = parsed + + const lookup = lookupTag(catalog, project, version) + switch (lookup.kind) { + case 'schema': + return unavailable( + project, + version, + `catalog.json is schema version ${lookup.found}, this plugin understands 1`, + 'update the plugin', + ) + case 'unknown-project': + return unavailable( + project, + version, + `${DOCS_REPO} publishes no project "${project}"`, + `known projects: ${lookup.known.join(', ') || 'none'}`, + ) + case 'unknown-version': + return unavailable( + project, + version, + `${DOCS_REPO} has not published ${project} ${version}`, + `open an issue at https://github.com/${DOCS_REPO}/issues to have it built`, + ) + case 'unpublished': + return unavailable( + project, + version, + `${DOCS_REPO} reserved ${lookup.tag} for ${project} ${version} but has published no archive under it`, + `open an issue at https://github.com/${DOCS_REPO}/issues to have it built`, + ) + } + + const { tag } = lookup + if (!isSafeSegment(tag)) { + return unavailable( + project, + version, + `catalog.json maps ${project} ${version} to an unusable tag "${tag}"`, + `report it at https://github.com/${DOCS_REPO}/issues`, + ) + } + + const target = docsCachePath(cacheHome, tag) + // Before the cache-hit return, not inside `unpack`: a refresh that died + // while the previous tree was still usable leaves debris that every later + // run then skips past, because those runs never reach the download. + sweepLeftovers(target) + // An incomplete tree falls through to a re-download rather than failing: + // repairing it is exactly what this function is for. + if (isUsableTree(target) && !refresh) { + writePointer(cacheHome, project, version, tag) + return ready(project, version, tag, target, true) + } + + const checksumText = await fetchText(fetchImpl, checksumUrl(tag, project, version)) + if (typeof checksumText !== 'string') + return unavailable(project, version, checksumText.error) + + const expected = parseChecksum(checksumText, archiveName(project, version)) + if (expected === undefined) { + return unavailable( + project, + version, + `the checksum published for ${tag} does not describe ${archiveName(project, version)}`, + ) + } + + let archive: Buffer + try { + const response = await fetchImpl(archiveUrl(tag, project, version)) + if (!response.ok) + return unavailable(project, version, `GET ${archiveUrl(tag, project, version)} → ${response.status}`) + archive = Buffer.from(await response.arrayBuffer()) + } + catch (err) { + return unavailable(project, version, `downloading ${tag} failed: ${err instanceof Error ? err.message : String(err)}`) + } + + const actual = createHash('sha256').update(archive).digest('hex') + if (actual !== expected) { + return unavailable( + project, + version, + `checksum mismatch for ${tag}: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…`, + 'nothing was written to the cache; retry, and report it if it persists', + ) + } + + try { + unpack(archive, project, version, target) + } + catch (err) { + return unavailable(project, version, `unpacking ${tag} failed: ${err instanceof Error ? err.message : String(err)}`) + } + + writePointer(cacheHome, project, version, tag) + return ready(project, version, tag, target, false) +} + +// ------------------------------ CLI ----------------------------------------- + +const USAGE = 'usage: bun run scripts/docs.ts [--refresh] [--no-fetch]' + +interface ParsedArgs { + project: string + version: string + refresh: boolean + noFetch: boolean +} + +export function parseArgs(argv: string[]): ParsedArgs | { error: string } { + const positional: string[] = [] + let refresh = false + let noFetch = false + for (const arg of argv) { + if (arg === '--refresh') + refresh = true + else if (arg === '--no-fetch') + noFetch = true + else if (arg.startsWith('--')) + return { error: `unknown argument: ${arg}` } + else positional.push(arg) + } + const [project, version, ...extra] = positional + if (!project) + return { error: 'missing ' } + if (!version) + return { error: 'missing ' } + if (extra.length > 0) + return { error: `unexpected argument: ${extra[0]}` } + return { project, version, refresh, noFetch } +} + +async function cli(argv: string[]): Promise { + const parsed = parseArgs(argv) + if ('error' in parsed) { + process.stderr.write(`${parsed.error}\n${USAGE}\n`) + return 2 + } + + let result: ResolveResult + try { + result = await resolveDocs(parsed) + } + catch (err) { + process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) + return 2 + } + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + return result.kind === 'ready' ? 0 : 1 +} + +if (import.meta.main) { + const code = await cli(process.argv.slice(2)) + process.exit(code) +} diff --git a/scripts/lib/docs-cache.ts b/scripts/lib/docs-cache.ts new file mode 100644 index 0000000..64d2915 --- /dev/null +++ b/scripts/lib/docs-cache.ts @@ -0,0 +1,205 @@ +/** + * Pure helpers for resolving published `pleaseai/spring-docs` archives. + * Library Layer: no I/O. + * + * The docs are not installed into the project. They are unpacked once into a + * shared cache and referenced by path, so a project keeps zero documentation + * files in version control and two projects on the same Spring version share + * one copy. `scripts/docs.ts` owns the network and filesystem side. + */ + +import { join } from 'node:path' + +/** Repository publishing the converted documentation archives. */ +export const DOCS_REPO = 'pleaseai/spring-docs' as const + +/** Raw `catalog.json` on the docs repository's default branch. */ +export const CATALOG_URL + = `https://raw.githubusercontent.com/${DOCS_REPO}/main/catalog.json` as const + +/** + * Relative path of the documentation cache inside the home directory. + * + * Shares the `pleaseai-spring` root with the override store + * (`scripts/lib/overrides.ts`), so clearing that one directory clears + * everything this plugin has written. + */ +export const DOCS_CACHE_SUBDIR = '.cache/pleaseai-spring/docs' as const + +/** Catalog schema version this client understands. */ +export const SUPPORTED_CATALOG_VERSION = '1' as const + +/** One published `(project, version)` pair. */ +export interface CatalogEntry { + /** Release tag carrying the archive, e.g. `boot-4.1.1`. */ + tag: string + /** ISO-8601 publication time, or null while a tag exists unpublished. */ + released_at: string | null +} + +/** The docs repository's master index. */ +export interface Catalog { + version: string + generated_at: string | null + projects: Record> +} + +/** Why a catalog lookup produced no tag. */ +export type LookupFailure + = | { kind: 'schema', found: string } + | { kind: 'unknown-project', project: string, known: string[] } + | { kind: 'unknown-version', project: string, version: string, known: string[] } + | { kind: 'unpublished', project: string, version: string, tag: string } + +export type LookupResult + = | { kind: 'found', tag: string, releasedAt: string | null } + | LookupFailure + +/** + * Find the release tag carrying `project` `version`. + * + * The catalog — not the tag naming scheme — is the authority: a corrected + * archive is republished under `-+rebuild.N` and only the + * catalog says which tag a version currently resolves to. + */ +export function lookupTag(catalog: Catalog, project: string, version: string): LookupResult { + if (catalog.version !== SUPPORTED_CATALOG_VERSION) + return { kind: 'schema', found: catalog.version } + + const versions = catalog.projects[project] + if (!versions) + return { kind: 'unknown-project', project, known: Object.keys(catalog.projects).sort() } + + const entry = versions[version] + if (!entry) + return { kind: 'unknown-version', project, version, known: Object.keys(versions) } + + // A null `released_at` is the catalog saying the tag is reserved but carries + // no assets yet. Downloading from it returns 404, which reads as an + // unreachable network rather than as the "not built yet" it is. + if (entry.released_at === null) + return { kind: 'unpublished', project, version, tag: entry.tag } + + return { kind: 'found', tag: entry.tag, releasedAt: entry.released_at } +} + +/** + * Basename of the archive asset for one `(project, version)` pair. + * + * Deliberately built from the pair rather than from the tag: a `+rebuild.N` + * tag still ships `-.tar.gz`, because the archive describes + * the documentation, not the attempt that published it. + */ +export function archiveName(project: string, version: string): string { + return `${project}-${version}.tar.gz` +} + +/** Download URL of an archive asset published under `tag`. */ +export function archiveUrl(tag: string, project: string, version: string): string { + return `https://github.com/${DOCS_REPO}/releases/download/${tag}/${archiveName(project, version)}` +} + +/** Download URL of the checksum published beside the archive. */ +export function checksumUrl(tag: string, project: string, version: string): string { + return `${archiveUrl(tag, project, version)}.sha256` +} + +/** + * True when `value` has the shape {@link lookupTag} reads. + * + * `catalog.json` is fetched over the network, so its shape is an assumption + * until checked. A bare `as Catalog` lets a valid-JSON body like `null` or + * `{"version":"1"}` throw a TypeError deep inside the lookup, which the CLI + * reports as an internal error instead of the documented unavailable result. + */ +export function isCatalog(value: unknown): value is Catalog { + if (!isObjectMap(value)) + return false + if (typeof value.version !== 'string') + return false + const { projects } = value + if (!isObjectMap(projects)) + return false + return Object.values(projects).every(isVersionMap) +} + +function isVersionMap(value: unknown): boolean { + if (!isObjectMap(value)) + return false + return Object.values(value).every((entry) => { + if (!isObjectMap(entry)) + return false + if (typeof entry.tag !== 'string') + return false + // `CatalogEntry` promises `string | null`, and `lookupTag` hands the value + // straight to callers. An absent key would satisfy neither yet pass a + // tag-only check, putting `undefined` behind a type that excludes it. + return entry.released_at === null || typeof entry.released_at === 'string' + }) +} + +/** + * True when `value` is a plain keyed object. + * + * `typeof` alone answers `'object'` for both `null` and an array, so a bare + * typeof check accepts `{"projects": []}` as a map of projects. It reads as + * empty rather than failing, which is how a malformed catalog turns into a + * confident "unknown project" instead of the schema error it is. + */ +function isObjectMap(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Characters a project, version or tag may contain to stay one path segment. */ +const SAFE_SEGMENT_RE = /^[\w.+-]+$/ + +/** + * True when `value` can be joined into a cache path without leaving it. + * + * A charset test alone is not enough: `.` and `..` are spelled entirely in + * allowed characters, and `join(home, subdir, '..')` climbs out of the cache + * just as effectively as a slash would. Both are rejected by name. + */ +export function isSafeSegment(value: string): boolean { + if (value === '.' || value === '..') + return false + return SAFE_SEGMENT_RE.test(value) +} + +/** + * Directory holding one unpacked archive. + * + * Keyed by tag, not by version: a `+rebuild.N` tag is a different archive for + * the same version, and keying by version would keep serving the superseded + * tree from cache forever. + * + * Callers must pass a tag {@link isSafeSegment} accepts — this joins whatever + * it is given, and the tree it names is both read from and `rmSync`'d. + */ +export function docsCachePath(cacheHome: string, tag: string): string { + return join(cacheHome, DOCS_CACHE_SUBDIR, tag) +} + +const SHA256_LINE_RE = /^([0-9a-f]{64})\s+\*?(\S+)$/i + +/** + * Read the digest out of a `sha256sum`-style checksum file. + * + * The filename is checked, not ignored: the sidecar is fetched from the same + * release as the archive, so a mismatch means the release's assets do not + * belong together and the digest is not evidence about these bytes. + * + * @returns the lowercase digest, or undefined when the file is malformed or + * names a different archive. + */ +export function parseChecksum(contents: string, expectedName: string): string | undefined { + const line = contents.trim().split('\n')[0]?.trim() + if (!line) + return undefined + const match = SHA256_LINE_RE.exec(line) + if (!match || !match[1] || !match[2]) + return undefined + if (match[2] !== expectedName) + return undefined + return match[1].toLowerCase() +} diff --git a/skills/spring-docs/SKILL.md b/skills/spring-docs/SKILL.md new file mode 100644 index 0000000..31599d4 --- /dev/null +++ b/skills/spring-docs/SKILL.md @@ -0,0 +1,72 @@ +--- +name: spring-docs +allowed-tools: + - Bash(node ${CLAUDE_SKILL_DIR}/scripts/docs.mjs *) + - Bash(node ${CLAUDE_SKILL_DIR}/scripts/detect.mjs *) +description: Open the reference documentation for one Spring project and version — Spring Boot 3.3.0-3.x and 4.0.8+. Use when answering a question about Spring behavior, configuration properties, auto-configuration, actuator, testing support or an upgrade path, and whenever the answer must match the version the project actually declares rather than the newest release. Takes " ", e.g. "boot 3.5.16". +--- + +# Spring reference documentation + +Resolves one `(project, version)` pair to a local directory of converted Markdown +and reads from there. The documentation is never copied into the user's project: +it is unpacked once into a shared cache, so nothing lands in version control and +two projects on the same Spring version share one copy. + +## Resolve the version + +Run this first, with the project key and the exact version: + +```bash +node ${CLAUDE_SKILL_DIR}/scripts/docs.mjs boot 3.5.16 +``` + +It prints JSON: + +```json +{ + "kind": "ready", + "project": "boot", + "version": "3.5.16", + "tag": "boot-3.5.16", + "path": "~/.cache/pleaseai-spring/docs/boot-3.5.16", + "index": "~/.cache/pleaseai-spring/docs/boot-3.5.16/_index.md", + "cached": true +} +``` + +Read `_index.md` at `index` to see the table of contents, then open only the +pages the question needs — the tree is 150-250 files, so never read it whole. +`Grep` across `path` when you know the term but not the page. + +Add `--no-fetch` to require a cache hit (offline), or `--refresh` to re-download. + +## Which version to pass + +Use the version the project declares, not the newest one. `scripts/detect.mjs` +reads it from `build.gradle`, `build.gradle.kts` or `pom.xml`: + +```bash +node ${CLAUDE_SKILL_DIR}/scripts/detect.mjs . +``` + +Ask the user only when detection returns `kind: "not-found"` or `"unsupported"`. + +## When a version is not published + +`kind: "unavailable"` is not a failure to work around. The `suggestion` field +says what to do — usually opening an issue on `pleaseai/spring-docs` so that +version gets built. Do not fall back to another version's documentation without +saying so: answering Spring questions from the wrong minor is the failure mode +this skill exists to prevent. Answer from general knowledge instead, and say +which version you are describing. + +## Coverage + +- `boot` — Spring Boot `3.3.0`-`3.x` and `4.0.8`+. 3.2 and older predate the + Antora documentation component; 4.0.0-4.0.7 publish no content archive. +- Spring Boot 3.x trees omit the generated appendix (auto-configuration class + listings, configuration-property tables) because upstream never publishes it. + Configuration properties for 3.x therefore have to come from the prose pages. +- Other Spring projects (framework, security, data) are not published yet; + `unknown-project` says so. diff --git a/skills/spring-docs/scripts/detect.mjs b/skills/spring-docs/scripts/detect.mjs new file mode 100644 index 0000000..069c548 --- /dev/null +++ b/skills/spring-docs/scripts/detect.mjs @@ -0,0 +1,4587 @@ +#!/usr/bin/env node +// Generated by `bun run build:skill` from scripts/detect.ts — do not edit. +// Regenerate and commit after changing anything under scripts/. +// scripts/detect.ts +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join as join3, relative, resolve } from "node:path"; +import process from "node:process"; + +// scripts/lib/detect-gradle-catalog.ts +var TABLE_HEADER_RE = /^\s*\[(.+?)\]\s*$/; +var TOML_KV_RE = /^\s*([\w.-]+)\s*=\s*(['"])([^'"]*)\2/; +var COMMENT_LINE_RE = /^\s*#/; +var QUOTE_OUTER_RE = /^(['"])(.*)\1$/; +var DOT_GLOBAL_RE = /\./g; +var LINE_BREAK_RE = /\r?\n/; +function resolveCatalogVersion(toml, aliasPath) { + const versionsTable = extractVersionsTable(toml); + if (!versionsTable) + return; + for (const key of aliasCandidates(aliasPath)) { + const v = readTomlString(versionsTable, key); + if (v) + return v; + } + return; +} +function aliasCandidates(aliasPath) { + const seen = new Set([aliasPath]); + seen.add(aliasPath.replace(DOT_GLOBAL_RE, "-")); + seen.add(aliasPath.replace(DOT_GLOBAL_RE, "_")); + seen.add(aliasPath.replace(DOT_GLOBAL_RE, "")); + return [...seen]; +} +function extractVersionsTable(toml) { + let inVersions = false; + const collected = []; + for (const line of toml.split(LINE_BREAK_RE)) { + const header = TABLE_HEADER_RE.exec(line); + if (header) { + inVersions = header[1]?.trim() === "versions"; + continue; + } + if (inVersions) + collected.push(line); + } + if (collected.length === 0) + return; + return collected.join(` +`); +} +function readTomlString(table, key) { + for (const raw of table.split(LINE_BREAK_RE)) { + if (COMMENT_LINE_RE.test(raw)) + continue; + const m = TOML_KV_RE.exec(raw); + if (m && m[1] === key) + return m[3] ?? undefined; + } + return; +} +function parseProperties(content) { + const out = {}; + for (const raw of content.split(LINE_BREAK_RE)) { + const kv = parseKvLine(raw); + if (!kv) + continue; + const [key, value] = kv; + out[key] = value; + } + return out; +} +function parseKvLine(raw) { + const trimmed = raw.trimStart(); + if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("!")) + return; + const sep = firstIndexOfAny(trimmed, "=", ":"); + if (sep <= 0) + return; + const key = trimmed.slice(0, sep).trim(); + if (!key) + return; + let value = trimmed.slice(sep + 1).trim(); + const quoted = QUOTE_OUTER_RE.exec(value); + if (quoted) + value = quoted[2] ?? ""; + return [key, value]; +} +function firstIndexOfAny(s, a, b) { + const ai = s.indexOf(a); + const bi = s.indexOf(b); + if (ai === -1) + return bi; + if (bi === -1) + return ai; + return Math.min(ai, bi); +} +function resolveProperty(content, name) { + return parseProperties(content)[name]; +} + +// scripts/lib/detect-gradle-settings.ts +var INCLUDE_CALL_RE = /(? 0) { + const ch = masked.charCodeAt(i); + if (ch === 123) + depth++; + else if (ch === 125) + depth--; + i++; + } + if (depth !== 0) + return; + return { keyword, openBrace, closeBrace: i - 1 }; +} +function stripComments(source) { + return maskNonCode(source, false); +} +function stripCommentsAndStrings(source) { + return maskNonCode(source, true); +} +function maskNonCode(source, maskStrings) { + const out = source.split(""); + const len = source.length; + let i = 0; + while (i < len) { + const c = source.charCodeAt(i); + const next = i + 1 < len ? source.charCodeAt(i + 1) : -1; + if (c === 47 && next === 47) { + while (i < len && source.charCodeAt(i) !== 10) { + out[i] = " "; + i++; + } + continue; + } + if (c === 47 && next === 42) { + const end = source.indexOf("*/", i + 2); + const stop = end === -1 ? len : end + 2; + maskRange(source, out, i, stop); + i = stop; + continue; + } + if (maskStrings && (c === 34 || c === 39)) { + const stop = endOfStringLiteral(source, i); + maskRange(source, out, i, stop); + i = stop; + continue; + } + i++; + } + return out.join(""); +} +function endOfStringLiteral(source, start) { + const len = source.length; + const quote = source.charCodeAt(start); + if (start + 2 < len && source.charCodeAt(start + 1) === quote && source.charCodeAt(start + 2) === quote) { + let i = start + 3; + while (i + 2 < len) { + if (source.charCodeAt(i) === quote && source.charCodeAt(i + 1) === quote && source.charCodeAt(i + 2) === quote) { + return i + 3; + } + i++; + } + return len; + } + let i = start + 1; + while (i < len) { + const ch = source.charCodeAt(i); + if (ch === 92 && i + 1 < len) { + i += 2; + continue; + } + if (ch === quote) + return i + 1; + if (ch === 10) + return i; + i++; + } + return len; +} +function maskRange(source, out, start, end) { + for (let j = start;j < end; j++) { + if (source.charCodeAt(j) !== 10) + out[j] = " "; + } +} + +// scripts/lib/detect-types.ts +var REQUIRES_BUILD_TOOL = "requires-build-tool"; +var SUGGEST_BOOT_OVERRIDE = "Use --boot to override"; + +// scripts/lib/detect-gradle.ts +var SPRING_BOOT_PLUGIN_ID_RE = /\bid\s*(?:\(\s*)?['"]org\.springframework\.boot['"]\s*\)?/g; +var APPLY_PLUGIN_RE = /\bapply\s*(?:\(\s*plugin\s*=\s*|plugin\s*:\s*)['"]org\.springframework\.boot['"]/; +var CLASSPATH_PLUGIN_RE = /\bclasspath\s*(?:\(\s*)?['"]org\.springframework\.boot:spring-boot-gradle-plugin/; +var PLUGINS_LITERAL_RE = /\bid\s*(?:\(\s*)?['"]org\.springframework\.boot['"]\s*(?:\)\s*)?version\s+['"]([^'"]+)['"]/; +var PLUGINS_CATALOG_RE = /\bid\s*(?:\(\s*)?['"]org\.springframework\.boot['"]\s*(?:\)\s*)?version\s+libs\.versions\.([\w.]+)\.get\s*\(\s*\)/; +var ASSIGN_VERSION_PATTERNS = [ + /\bext\s*\.\s*springBootVersion\s*=\s*['"]([^'"$\\]+)['"]/, + /\bext\s*\[\s*['"]spring-boot\.version['"]\s*\]\s*=\s*['"]([^'"$\\]+)['"]/, + /\bclasspath\s*(?:\(\s*)?['"]org\.springframework\.boot:spring-boot-gradle-plugin:([^'"$\\{}]+)['"]/, + /\bspringBootVersion\s*=\s*['"]([^'"$\\]+)['"]/ +]; +var INTERPOLATION_BRACED_RE = /^\$\{(\w+)\}$/; +var INTERPOLATION_BARE_RE = /^\$(\w+)$/; +function parseGradle(source, file) { + const pluginReferenced = SPRING_BOOT_PLUGIN_ID_RE.test(source) || APPLY_PLUGIN_RE.test(source) || CLASSPATH_PLUGIN_RE.test(source); + SPRING_BOOT_PLUGIN_ID_RE.lastIndex = 0; + const literal = PLUGINS_LITERAL_RE.exec(source); + if (literal) { + const raw = literal[1]; + if (raw === undefined) + return notFoundWithHints(file, { pluginReferenced }); + if (raw.includes("${") || raw.startsWith("$")) { + const name = extractInterpolationName(raw); + return { + result: notFound(file), + hints: { + pluginReferenced: true, + ...name ? { propertyReference: { name } } : {} + } + }; + } + return { + result: detected(raw, file, "plugins block id org.springframework.boot", lineOf(source, literal.index)), + hints: { pluginReferenced: true } + }; + } + const catalog = PLUGINS_CATALOG_RE.exec(source); + if (catalog && catalog[1]) { + return { + result: notFound(file), + hints: { + pluginReferenced: true, + catalogReference: { aliasPath: catalog[1] } + } + }; + } + if (pluginReferenced) { + for (const p of ASSIGN_VERSION_PATTERNS) { + const m = p.exec(source); + if (m && m[1]) { + return { + result: detected(m[1], file, "buildscript ext / classpath version assignment", lineOf(source, m.index)), + hints: { pluginReferenced: true } + }; + } + } + } + return notFoundWithHints(file, { pluginReferenced }); +} +function detected(version, file, locator, line) { + return { + kind: "detected", + version, + source: { file, locator, line } + }; +} +function notFound(file) { + return { + kind: "not-found", + reason: `No Spring Boot version declared in ${file}`, + suggestion: `Run from a Spring project root, or pass --boot to override (${SUGGEST_BOOT_OVERRIDE})` + }; +} +function notFoundWithHints(file, hints) { + return { result: notFound(file), hints }; +} +function lineOf(source, index) { + let line = 1; + for (let i = 0;i < index && i < source.length; i++) { + if (source.charCodeAt(i) === 10) + line++; + } + return line; +} +function extractInterpolationName(raw) { + const braced = INTERPOLATION_BRACED_RE.exec(raw); + if (braced && braced[1]) + return braced[1]; + const bare = INTERPOLATION_BARE_RE.exec(raw); + if (bare && bare[1]) + return bare[1]; + return; +} + +// node_modules/fast-xml-parser/src/util.js +var nameStartChar = ":A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD"; +var nameChar = nameStartChar + "\\-.\\d\\u00B7\\u0300-\\u036F\\u203F-\\u2040"; +var nameRegexp = "[" + nameStartChar + "][" + nameChar + "]*"; +var regexName = new RegExp("^" + nameRegexp + "$"); +function getAllMatches(string, regex) { + const matches = []; + let match = regex.exec(string); + while (match) { + const allmatches = []; + allmatches.startIndex = regex.lastIndex - match[0].length; + const len = match.length; + for (let index = 0;index < len; index++) { + allmatches.push(match[index]); + } + matches.push(allmatches); + match = regex.exec(string); + } + return matches; +} +var isName = function(string) { + const match = regexName.exec(string); + return !(match === null || typeof match === "undefined"); +}; +function isExist(v) { + return typeof v !== "undefined"; +} +var DANGEROUS_PROPERTY_NAMES = [ + "hasOwnProperty", + "toString", + "valueOf", + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__" +]; +var criticalProperties = ["__proto__", "constructor", "prototype"]; + +// node_modules/fast-xml-parser/src/validator.js +var defaultOptions = { + allowBooleanAttributes: false, + unpairedTags: [] +}; +function validate(xmlData, options) { + options = Object.assign({}, defaultOptions, options); + const tags = []; + let tagFound = false; + let reachedRoot = false; + if (xmlData[0] === "\uFEFF") { + xmlData = xmlData.substr(1); + } + for (let i = 0;i < xmlData.length; i++) { + if (xmlData[i] === "<" && xmlData[i + 1] === "?") { + i += 2; + i = readPI(xmlData, i); + if (i.err) + return i; + } else if (xmlData[i] === "<") { + let tagStartPos = i; + i++; + if (xmlData[i] === "!") { + i = readCommentAndCDATA(xmlData, i); + continue; + } else { + let closingTag = false; + if (xmlData[i] === "/") { + closingTag = true; + i++; + } + let tagName = ""; + for (;i < xmlData.length && xmlData[i] !== ">" && xmlData[i] !== " " && xmlData[i] !== "\t" && xmlData[i] !== ` +` && xmlData[i] !== "\r"; i++) { + tagName += xmlData[i]; + } + tagName = tagName.trim(); + if (tagName[tagName.length - 1] === "/") { + tagName = tagName.substring(0, tagName.length - 1); + i--; + } + if (!validateTagName(tagName)) { + let msg; + if (tagName.trim().length === 0) { + msg = "Invalid space after '<'."; + } else { + msg = "Tag '" + tagName + "' is an invalid name."; + } + return getErrorObject("InvalidTag", msg, getLineNumberForPosition(xmlData, i)); + } + const result = readAttributeStr(xmlData, i); + if (result === false) { + return getErrorObject("InvalidAttr", "Attributes for '" + tagName + "' have open quote.", getLineNumberForPosition(xmlData, i)); + } + let attrStr = result.value; + i = result.index; + if (attrStr[attrStr.length - 1] === "/") { + const attrStrStart = i - attrStr.length; + attrStr = attrStr.substring(0, attrStr.length - 1); + const isValid = validateAttributeString(attrStr, options); + if (isValid === true) { + tagFound = true; + } else { + return getErrorObject(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, attrStrStart + isValid.err.line)); + } + } else if (closingTag) { + if (!result.tagClosed) { + return getErrorObject("InvalidTag", "Closing tag '" + tagName + "' doesn't have proper closing.", getLineNumberForPosition(xmlData, i)); + } else if (attrStr.trim().length > 0) { + return getErrorObject("InvalidTag", "Closing tag '" + tagName + "' can't have attributes or invalid starting.", getLineNumberForPosition(xmlData, tagStartPos)); + } else if (tags.length === 0) { + return getErrorObject("InvalidTag", "Closing tag '" + tagName + "' has not been opened.", getLineNumberForPosition(xmlData, tagStartPos)); + } else { + const otg = tags.pop(); + if (tagName !== otg.tagName) { + let openPos = getLineNumberForPosition(xmlData, otg.tagStartPos); + return getErrorObject("InvalidTag", "Expected closing tag '" + otg.tagName + "' (opened in line " + openPos.line + ", col " + openPos.col + ") instead of closing tag '" + tagName + "'.", getLineNumberForPosition(xmlData, tagStartPos)); + } + if (tags.length == 0) { + reachedRoot = true; + } + } + } else { + const isValid = validateAttributeString(attrStr, options); + if (isValid !== true) { + return getErrorObject(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, i - attrStr.length + isValid.err.line)); + } + if (reachedRoot === true) { + return getErrorObject("InvalidXml", "Multiple possible root nodes found.", getLineNumberForPosition(xmlData, i)); + } else if (options.unpairedTags.indexOf(tagName) !== -1) {} else { + tags.push({ tagName, tagStartPos }); + } + tagFound = true; + } + for (i++;i < xmlData.length; i++) { + if (xmlData[i] === "<") { + if (xmlData[i + 1] === "!") { + i++; + i = readCommentAndCDATA(xmlData, i); + continue; + } else if (xmlData[i + 1] === "?") { + i = readPI(xmlData, ++i); + if (i.err) + return i; + } else { + break; + } + } else if (xmlData[i] === "&") { + const afterAmp = validateAmpersand(xmlData, i); + if (afterAmp == -1) + return getErrorObject("InvalidChar", "char '&' is not expected.", getLineNumberForPosition(xmlData, i)); + i = afterAmp; + } else { + if (reachedRoot === true && !isWhiteSpace(xmlData[i])) { + return getErrorObject("InvalidXml", "Extra text at the end", getLineNumberForPosition(xmlData, i)); + } + } + } + if (xmlData[i] === "<") { + i--; + } + } + } else { + if (isWhiteSpace(xmlData[i])) { + continue; + } + return getErrorObject("InvalidChar", "char '" + xmlData[i] + "' is not expected.", getLineNumberForPosition(xmlData, i)); + } + } + if (!tagFound) { + return getErrorObject("InvalidXml", "Start tag expected.", 1); + } else if (tags.length == 1) { + return getErrorObject("InvalidTag", "Unclosed tag '" + tags[0].tagName + "'.", getLineNumberForPosition(xmlData, tags[0].tagStartPos)); + } else if (tags.length > 0) { + return getErrorObject("InvalidXml", "Invalid '" + JSON.stringify(tags.map((t) => t.tagName), null, 4).replace(/\r?\n/g, "") + "' found.", { line: 1, col: 1 }); + } + return true; +} +function isWhiteSpace(char) { + return char === " " || char === "\t" || char === ` +` || char === "\r"; +} +function readPI(xmlData, i) { + const start = i; + for (;i < xmlData.length; i++) { + if (xmlData[i] == "?" || xmlData[i] == " ") { + const tagname = xmlData.substr(start, i - start); + if (i > 5 && tagname === "xml") { + return getErrorObject("InvalidXml", "XML declaration allowed only at the start of the document.", getLineNumberForPosition(xmlData, i)); + } else if (xmlData[i] == "?" && xmlData[i + 1] == ">") { + i++; + break; + } else { + continue; + } + } + } + return i; +} +function readCommentAndCDATA(xmlData, i) { + if (xmlData.length > i + 5 && xmlData[i + 1] === "-" && xmlData[i + 2] === "-") { + for (i += 3;i < xmlData.length; i++) { + if (xmlData[i] === "-" && xmlData[i + 1] === "-" && xmlData[i + 2] === ">") { + i += 2; + break; + } + } + } else if (xmlData.length > i + 8 && xmlData[i + 1] === "D" && xmlData[i + 2] === "O" && xmlData[i + 3] === "C" && xmlData[i + 4] === "T" && xmlData[i + 5] === "Y" && xmlData[i + 6] === "P" && xmlData[i + 7] === "E") { + let angleBracketsCount = 1; + for (i += 8;i < xmlData.length; i++) { + if (xmlData[i] === "<") { + angleBracketsCount++; + } else if (xmlData[i] === ">") { + angleBracketsCount--; + if (angleBracketsCount === 0) { + break; + } + } + } + } else if (xmlData.length > i + 9 && xmlData[i + 1] === "[" && xmlData[i + 2] === "C" && xmlData[i + 3] === "D" && xmlData[i + 4] === "A" && xmlData[i + 5] === "T" && xmlData[i + 6] === "A" && xmlData[i + 7] === "[") { + for (i += 8;i < xmlData.length; i++) { + if (xmlData[i] === "]" && xmlData[i + 1] === "]" && xmlData[i + 2] === ">") { + i += 2; + break; + } + } + } + return i; +} +var doubleQuote = '"'; +var singleQuote = "'"; +function readAttributeStr(xmlData, i) { + let attrStr = ""; + let startChar = ""; + let tagClosed = false; + for (;i < xmlData.length; i++) { + if (xmlData[i] === doubleQuote || xmlData[i] === singleQuote) { + if (startChar === "") { + startChar = xmlData[i]; + } else if (startChar !== xmlData[i]) {} else { + startChar = ""; + } + } else if (xmlData[i] === ">") { + if (startChar === "") { + tagClosed = true; + break; + } + } + attrStr += xmlData[i]; + } + if (startChar !== "") { + return false; + } + return { + value: attrStr, + index: i, + tagClosed + }; +} +var validAttrStrRegxp = new RegExp(`(\\s*)([^\\s=]+)(\\s*=)?(\\s*(['"])(([\\s\\S])*?)\\5)?`, "g"); +function validateAttributeString(attrStr, options) { + const matches = getAllMatches(attrStr, validAttrStrRegxp); + const attrNames = {}; + for (let i = 0;i < matches.length; i++) { + if (matches[i][1].length === 0) { + return getErrorObject("InvalidAttr", "Attribute '" + matches[i][2] + "' has no space in starting.", getPositionFromMatch(matches[i])); + } else if (matches[i][3] !== undefined && matches[i][4] === undefined) { + return getErrorObject("InvalidAttr", "Attribute '" + matches[i][2] + "' is without value.", getPositionFromMatch(matches[i])); + } else if (matches[i][3] === undefined && !options.allowBooleanAttributes) { + return getErrorObject("InvalidAttr", "boolean attribute '" + matches[i][2] + "' is not allowed.", getPositionFromMatch(matches[i])); + } + const attrName = matches[i][2]; + if (!validateAttrName(attrName)) { + return getErrorObject("InvalidAttr", "Attribute '" + attrName + "' is an invalid name.", getPositionFromMatch(matches[i])); + } + if (!Object.prototype.hasOwnProperty.call(attrNames, attrName)) { + attrNames[attrName] = 1; + } else { + return getErrorObject("InvalidAttr", "Attribute '" + attrName + "' is repeated.", getPositionFromMatch(matches[i])); + } + } + return true; +} +function validateNumberAmpersand(xmlData, i) { + let re = /\d/; + if (xmlData[i] === "x") { + i++; + re = /[\da-fA-F]/; + } + for (;i < xmlData.length; i++) { + if (xmlData[i] === ";") + return i; + if (!xmlData[i].match(re)) + break; + } + return -1; +} +function validateAmpersand(xmlData, i) { + i++; + if (xmlData[i] === ";") + return -1; + if (xmlData[i] === "#") { + i++; + return validateNumberAmpersand(xmlData, i); + } + let count = 0; + for (;i < xmlData.length; i++, count++) { + if (xmlData[i].match(/\w/) && count < 20) + continue; + if (xmlData[i] === ";") + break; + return -1; + } + return i; +} +function getErrorObject(code, message, lineNumber) { + return { + err: { + code, + msg: message, + line: lineNumber.line || lineNumber, + col: lineNumber.col + } + }; +} +function validateAttrName(attrName) { + return isName(attrName); +} +function validateTagName(tagname) { + return isName(tagname); +} +function getLineNumberForPosition(xmlData, index) { + const lines = xmlData.substring(0, index).split(/\r?\n/); + return { + line: lines.length, + col: lines[lines.length - 1].length + 1 + }; +} +function getPositionFromMatch(match) { + return match.startIndex + match[1].length; +} + +// node_modules/@nodable/entities/src/entities.js +var BASIC_LATIN = { + amp: "&", + AMP: "&", + lt: "<", + LT: "<", + gt: ">", + GT: ">", + quot: '"', + QUOT: '"', + apos: "'", + lsquo: "‘", + rsquo: "’", + ldquo: "“", + rdquo: "”", + lsquor: "‚", + rsquor: "’", + ldquor: "„", + bdquo: "„", + comma: ",", + period: ".", + colon: ":", + semi: ";", + excl: "!", + quest: "?", + num: "#", + dollar: "$", + percent: "%", + amp: "&", + ast: "*", + commat: "@", + lowbar: "_", + verbar: "|", + vert: "|", + sol: "/", + bsol: "\\", + lbrace: "{", + rbrace: "}", + lbrack: "[", + rbrack: "]", + lpar: "(", + rpar: ")", + nbsp: " ", + iexcl: "¡", + cent: "¢", + pound: "£", + curren: "¤", + yen: "¥", + brvbar: "¦", + sect: "§", + uml: "¨", + copy: "©", + COPY: "©", + ordf: "ª", + laquo: "«", + not: "¬", + shy: "­", + reg: "®", + REG: "®", + macr: "¯", + deg: "°", + plusmn: "±", + sup2: "²", + sup3: "³", + acute: "´", + micro: "µ", + para: "¶", + middot: "·", + cedil: "¸", + sup1: "¹", + ordm: "º", + raquo: "»", + frac14: "¼", + frac12: "½", + half: "½", + frac34: "¾", + iquest: "¿", + times: "×", + div: "÷", + divide: "÷" +}; +var LATIN_ACCENTS = { + Agrave: "À", + agrave: "à", + Aacute: "Á", + aacute: "á", + Acirc: "Â", + acirc: "â", + Atilde: "Ã", + atilde: "ã", + Auml: "Ä", + auml: "ä", + Aring: "Å", + aring: "å", + AElig: "Æ", + aelig: "æ", + Ccedil: "Ç", + ccedil: "ç", + Egrave: "È", + egrave: "è", + Eacute: "É", + eacute: "é", + Ecirc: "Ê", + ecirc: "ê", + Euml: "Ë", + euml: "ë", + Igrave: "Ì", + igrave: "ì", + Iacute: "Í", + iacute: "í", + Icirc: "Î", + icirc: "î", + Iuml: "Ï", + iuml: "ï", + ETH: "Ð", + eth: "ð", + Ntilde: "Ñ", + ntilde: "ñ", + Ograve: "Ò", + ograve: "ò", + Oacute: "Ó", + oacute: "ó", + Ocirc: "Ô", + ocirc: "ô", + Otilde: "Õ", + otilde: "õ", + Ouml: "Ö", + ouml: "ö", + Oslash: "Ø", + oslash: "ø", + Ugrave: "Ù", + ugrave: "ù", + Uacute: "Ú", + uacute: "ú", + Ucirc: "Û", + ucirc: "û", + Uuml: "Ü", + uuml: "ü", + Yacute: "Ý", + yacute: "ý", + THORN: "Þ", + thorn: "þ", + szlig: "ß", + yuml: "ÿ", + Yuml: "Ÿ" +}; +var LATIN_EXTENDED = { + Amacr: "Ā", + amacr: "ā", + Abreve: "Ă", + abreve: "ă", + Aogon: "Ą", + aogon: "ą", + Cacute: "Ć", + cacute: "ć", + Ccirc: "Ĉ", + ccirc: "ĉ", + Cdot: "Ċ", + cdot: "ċ", + Ccaron: "Č", + ccaron: "č", + Dcaron: "Ď", + dcaron: "ď", + Dstrok: "Đ", + dstrok: "đ", + Emacr: "Ē", + emacr: "ē", + Ecaron: "Ě", + ecaron: "ě", + Edot: "Ė", + edot: "ė", + Eogon: "Ę", + eogon: "ę", + Gcirc: "Ĝ", + gcirc: "ĝ", + Gbreve: "Ğ", + gbreve: "ğ", + Gdot: "Ġ", + gdot: "ġ", + Gcedil: "Ģ", + Hcirc: "Ĥ", + hcirc: "ĥ", + Hstrok: "Ħ", + hstrok: "ħ", + Itilde: "Ĩ", + itilde: "ĩ", + Imacr: "Ī", + imacr: "ī", + Iogon: "Į", + iogon: "į", + Idot: "İ", + IJlig: "IJ", + ijlig: "ij", + Jcirc: "Ĵ", + jcirc: "ĵ", + Kcedil: "Ķ", + kcedil: "ķ", + kgreen: "ĸ", + Lacute: "Ĺ", + lacute: "ĺ", + Lcedil: "Ļ", + lcedil: "ļ", + Lcaron: "Ľ", + lcaron: "ľ", + Lmidot: "Ŀ", + lmidot: "ŀ", + Lstrok: "Ł", + lstrok: "ł", + Nacute: "Ń", + nacute: "ń", + Ncaron: "Ň", + ncaron: "ň", + Ncedil: "Ņ", + ncedil: "ņ", + ENG: "Ŋ", + eng: "ŋ", + Omacr: "Ō", + omacr: "ō", + Odblac: "Ő", + odblac: "ő", + OElig: "Œ", + oelig: "œ", + Racute: "Ŕ", + racute: "ŕ", + Rcaron: "Ř", + rcaron: "ř", + Rcedil: "Ŗ", + rcedil: "ŗ", + Sacute: "Ś", + sacute: "ś", + Scirc: "Ŝ", + scirc: "ŝ", + Scedil: "Ş", + scedil: "ş", + Scaron: "Š", + scaron: "š", + Tcedil: "Ţ", + tcedil: "ţ", + Tcaron: "Ť", + tcaron: "ť", + Tstrok: "Ŧ", + tstrok: "ŧ", + Utilde: "Ũ", + utilde: "ũ", + Umacr: "Ū", + umacr: "ū", + Ubreve: "Ŭ", + ubreve: "ŭ", + Uring: "Ů", + uring: "ů", + Udblac: "Ű", + udblac: "ű", + Uogon: "Ų", + uogon: "ų", + Wcirc: "Ŵ", + wcirc: "ŵ", + Ycirc: "Ŷ", + ycirc: "ŷ", + Zacute: "Ź", + zacute: "ź", + Zdot: "Ż", + zdot: "ż", + Zcaron: "Ž", + zcaron: "ž" +}; +var GREEK = { + Alpha: "Α", + alpha: "α", + Beta: "Β", + beta: "β", + Gamma: "Γ", + gamma: "γ", + Delta: "Δ", + delta: "δ", + Epsilon: "Ε", + epsilon: "ε", + epsiv: "ϵ", + varepsilon: "ϵ", + Zeta: "Ζ", + zeta: "ζ", + Eta: "Η", + eta: "η", + Theta: "Θ", + theta: "θ", + thetasym: "ϑ", + vartheta: "ϑ", + Iota: "Ι", + iota: "ι", + Kappa: "Κ", + kappa: "κ", + kappav: "ϰ", + varkappa: "ϰ", + Lambda: "Λ", + lambda: "λ", + Mu: "Μ", + mu: "μ", + Nu: "Ν", + nu: "ν", + Xi: "Ξ", + xi: "ξ", + Omicron: "Ο", + omicron: "ο", + Pi: "Π", + pi: "π", + piv: "ϖ", + varpi: "ϖ", + Rho: "Ρ", + rho: "ρ", + rhov: "ϱ", + varrho: "ϱ", + Sigma: "Σ", + sigma: "σ", + sigmaf: "ς", + sigmav: "ς", + varsigma: "ς", + Tau: "Τ", + tau: "τ", + Upsilon: "Υ", + upsilon: "υ", + upsi: "υ", + Upsi: "ϒ", + upsih: "ϒ", + Phi: "Φ", + phi: "φ", + phiv: "ϕ", + varphi: "ϕ", + Chi: "Χ", + chi: "χ", + Psi: "Ψ", + psi: "ψ", + Omega: "Ω", + omega: "ω", + ohm: "Ω", + Gammad: "Ϝ", + gammad: "ϝ", + digamma: "ϝ" +}; +var CYRILLIC = { + Afr: "\uD835\uDD04", + afr: "\uD835\uDD1E", + Acy: "А", + acy: "а", + Bcy: "Б", + bcy: "б", + Vcy: "В", + vcy: "в", + Gcy: "Г", + gcy: "г", + Dcy: "Д", + dcy: "д", + IEcy: "Е", + iecy: "е", + IOcy: "Ё", + iocy: "ё", + ZHcy: "Ж", + zhcy: "ж", + Zcy: "З", + zcy: "з", + Icy: "И", + icy: "и", + Jcy: "Й", + jcy: "й", + Kcy: "К", + kcy: "к", + Lcy: "Л", + lcy: "л", + Mcy: "М", + mcy: "м", + Ncy: "Н", + ncy: "н", + Ocy: "О", + ocy: "о", + Pcy: "П", + pcy: "п", + Rcy: "Р", + rcy: "р", + Scy: "С", + scy: "с", + Tcy: "Т", + tcy: "т", + Ucy: "У", + ucy: "у", + Fcy: "Ф", + fcy: "ф", + KHcy: "Х", + khcy: "х", + TScy: "Ц", + tscy: "ц", + CHcy: "Ч", + chcy: "ч", + SHcy: "Ш", + shcy: "ш", + SHCHcy: "Щ", + shchcy: "щ", + HARDcy: "Ъ", + hardcy: "ъ", + Ycy: "Ы", + ycy: "ы", + SOFTcy: "Ь", + softcy: "ь", + Ecy: "Э", + ecy: "э", + YUcy: "Ю", + yucy: "ю", + YAcy: "Я", + yacy: "я", + DJcy: "Ђ", + djcy: "ђ", + GJcy: "Ѓ", + gjcy: "ѓ", + Jukcy: "Є", + jukcy: "є", + DScy: "Ѕ", + dscy: "ѕ", + Iukcy: "І", + iukcy: "і", + YIcy: "Ї", + yicy: "ї", + Jsercy: "Ј", + jsercy: "ј", + LJcy: "Љ", + ljcy: "љ", + NJcy: "Њ", + njcy: "њ", + TSHcy: "Ћ", + tshcy: "ћ", + KJcy: "Ќ", + kjcy: "ќ", + Ubrcy: "Ў", + ubrcy: "ў", + DZcy: "Џ", + dzcy: "џ" +}; +var MATH = { + plus: "+", + minus: "−", + mnplus: "∓", + mp: "∓", + pm: "±", + times: "×", + div: "÷", + divide: "÷", + sdot: "⋅", + star: "☆", + starf: "★", + bigstar: "★", + lowast: "∗", + ast: "*", + midast: "*", + compfn: "∘", + smallcircle: "∘", + bullet: "•", + bull: "•", + nbsp: " ", + hellip: "…", + mldr: "…", + prime: "′", + Prime: "″", + tprime: "‴", + bprime: "‵", + backprime: "‵", + minus: "−", + minusd: "∸", + dotminus: "∸", + plusdo: "∔", + dotplus: "∔", + plusmn: "±", + minusplus: "∓", + mnplus: "∓", + mp: "∓", + setminus: "∖", + smallsetminus: "∖", + Backslash: "∖", + setmn: "∖", + ssetmn: "∖", + lowbar: "_", + verbar: "|", + vert: "|", + VerticalLine: "|", + colon: ":", + Colon: "∷", + Proportion: "∷", + ratio: "∶", + equals: "=", + ne: "≠", + nequiv: "≢", + equiv: "≡", + Congruent: "≡", + sim: "∼", + thicksim: "∼", + thksim: "∼", + sime: "≃", + simeq: "≃", + TildeEqual: "≃", + asymp: "≈", + approx: "≈", + thickapprox: "≈", + thkap: "≈", + TildeTilde: "≈", + ncong: "≇", + cong: "≅", + TildeFullEqual: "≅", + asympeq: "≍", + CupCap: "≍", + bump: "≎", + Bumpeq: "≎", + HumpDownHump: "≎", + bumpe: "≏", + bumpeq: "≏", + HumpEqual: "≏", + dotminus: "∸", + minusd: "∸", + plusdo: "∔", + dotplus: "∔", + le: "≤", + LessEqual: "≤", + ge: "≥", + GreaterEqual: "≥", + lesseqgtr: "⋚", + lesseqqgtr: "⪋", + greater: ">", + less: "<" +}; +var MATH_ADVANCED = { + alefsym: "ℵ", + aleph: "ℵ", + beth: "ℶ", + gimel: "ℷ", + daleth: "ℸ", + forall: "∀", + ForAll: "∀", + part: "∂", + PartialD: "∂", + exist: "∃", + Exists: "∃", + nexist: "∄", + nexists: "∄", + empty: "∅", + emptyset: "∅", + emptyv: "∅", + varnothing: "∅", + nabla: "∇", + Del: "∇", + isin: "∈", + isinv: "∈", + in: "∈", + Element: "∈", + notin: "∉", + notinva: "∉", + ni: "∋", + niv: "∋", + SuchThat: "∋", + ReverseElement: "∋", + notni: "∌", + notniva: "∌", + prod: "∏", + Product: "∏", + coprod: "∐", + Coproduct: "∐", + sum: "∑", + Sum: "∑", + minus: "−", + mp: "∓", + plusdo: "∔", + dotplus: "∔", + setminus: "∖", + lowast: "∗", + radic: "√", + Sqrt: "√", + prop: "∝", + propto: "∝", + Proportional: "∝", + varpropto: "∝", + infin: "∞", + infintie: "⧝", + ang: "∠", + angle: "∠", + angmsd: "∡", + measuredangle: "∡", + angsph: "∢", + mid: "∣", + VerticalBar: "∣", + nmid: "∤", + nsmid: "∤", + npar: "∦", + parallel: "∥", + spar: "∥", + nparallel: "∦", + nspar: "∦", + and: "∧", + wedge: "∧", + or: "∨", + vee: "∨", + cap: "∩", + cup: "∪", + int: "∫", + Integral: "∫", + conint: "∮", + ContourIntegral: "∮", + Conint: "∯", + DoubleContourIntegral: "∯", + Cconint: "∰", + there4: "∴", + therefore: "∴", + Therefore: "∴", + becaus: "∵", + because: "∵", + Because: "∵", + ratio: "∶", + Proportion: "∷", + minusd: "∸", + dotminus: "∸", + mDDot: "∺", + homtht: "∻", + sim: "∼", + bsimg: "∽", + backsim: "∽", + ac: "∾", + mstpos: "∾", + acd: "∿", + VerticalTilde: "≀", + wr: "≀", + wreath: "≀", + nsime: "≄", + nsimeq: "≄", + nsimeq: "≄", + ncong: "≇", + simne: "≆", + ncongdot: "⩭̸", + ngsim: "≵", + nsim: "≁", + napprox: "≉", + nap: "≉", + ngeq: "≱", + nge: "≱", + nleq: "≰", + nle: "≰", + ngtr: "≯", + ngt: "≯", + nless: "≮", + nlt: "≮", + nprec: "⊀", + npr: "⊀", + nsucc: "⊁", + nsc: "⊁" +}; +var ARROWS = { + larr: "←", + leftarrow: "←", + LeftArrow: "←", + uarr: "↑", + uparrow: "↑", + UpArrow: "↑", + rarr: "→", + rightarrow: "→", + RightArrow: "→", + darr: "↓", + downarrow: "↓", + DownArrow: "↓", + harr: "↔", + leftrightarrow: "↔", + LeftRightArrow: "↔", + varr: "↕", + updownarrow: "↕", + UpDownArrow: "↕", + nwarr: "↖", + nwarrow: "↖", + UpperLeftArrow: "↖", + nearr: "↗", + nearrow: "↗", + UpperRightArrow: "↗", + searr: "↘", + searrow: "↘", + LowerRightArrow: "↘", + swarr: "↙", + swarrow: "↙", + LowerLeftArrow: "↙", + lArr: "⇐", + Leftarrow: "⇐", + uArr: "⇑", + Uparrow: "⇑", + rArr: "⇒", + Rightarrow: "⇒", + dArr: "⇓", + Downarrow: "⇓", + hArr: "⇔", + Leftrightarrow: "⇔", + iff: "⇔", + vArr: "⇕", + Updownarrow: "⇕", + lAarr: "⇚", + Lleftarrow: "⇚", + rAarr: "⇛", + Rrightarrow: "⇛", + lrarr: "⇆", + leftrightarrows: "⇆", + rlarr: "⇄", + rightleftarrows: "⇄", + lrhar: "⇋", + leftrightharpoons: "⇋", + ReverseEquilibrium: "⇋", + rlhar: "⇌", + rightleftharpoons: "⇌", + Equilibrium: "⇌", + udarr: "⇅", + UpArrowDownArrow: "⇅", + duarr: "⇵", + DownArrowUpArrow: "⇵", + llarr: "⇇", + leftleftarrows: "⇇", + rrarr: "⇉", + rightrightarrows: "⇉", + ddarr: "⇊", + downdownarrows: "⇊", + har: "↽", + lhard: "↽", + leftharpoondown: "↽", + lharu: "↼", + leftharpoonup: "↼", + rhard: "⇁", + rightharpoondown: "⇁", + rharu: "⇀", + rightharpoonup: "⇀", + lsh: "↰", + Lsh: "↰", + rsh: "↱", + Rsh: "↱", + ldsh: "↲", + rdsh: "↳", + hookleftarrow: "↩", + hookrightarrow: "↪", + mapstoleft: "↤", + mapstoup: "↥", + map: "↦", + mapsto: "↦", + mapstodown: "↧", + crarr: "↵", + nwarrow: "↖", + nearrow: "↗", + searrow: "↘", + swarrow: "↙", + nleftarrow: "↚", + nleftrightarrow: "↮", + nrightarrow: "↛", + nrarr: "↛", + larrtl: "↢", + rarrtl: "↣", + leftarrowtail: "↢", + rightarrowtail: "↣", + twoheadleftarrow: "↞", + twoheadrightarrow: "↠", + Larr: "↞", + Rarr: "↠", + larrhk: "↩", + rarrhk: "↪", + larrlp: "↫", + looparrowleft: "↫", + rarrlp: "↬", + looparrowright: "↬", + harrw: "↭", + leftrightsquigarrow: "↭", + nrarrw: "↝̸", + rarrw: "↝", + rightsquigarrow: "↝", + larrbfs: "⤟", + rarrbfs: "⤠", + nvHarr: "⤄", + nvlArr: "⤂", + nvrArr: "⤃", + larrfs: "⤝", + rarrfs: "⤞", + Map: "⤅", + larrsim: "⥳", + rarrsim: "⥴", + harrcir: "⥈", + Uarrocir: "⥉", + lurdshar: "⥊", + ldrdhar: "⥧", + ldrushar: "⥋", + rdldhar: "⥩", + lrhard: "⥭", + rlhar: "⇌", + uharr: "↾", + uharl: "↿", + dharr: "⇂", + dharl: "⇃", + Uarr: "↟", + Darr: "↡", + zigrarr: "⇝", + nwArr: "⇖", + neArr: "⇗", + seArr: "⇘", + swArr: "⇙", + nharr: "↮", + nhArr: "⇎", + nlarr: "↚", + nlArr: "⇍", + nrarr: "↛", + nrArr: "⇏", + larrb: "⇤", + LeftArrowBar: "⇤", + rarrb: "⇥", + RightArrowBar: "⇥" +}; +var SHAPES = { + square: "□", + Square: "□", + squ: "□", + squf: "▪", + squarf: "▪", + blacksquar: "▪", + blacksquare: "▪", + FilledVerySmallSquare: "▪", + blk34: "▓", + blk12: "▒", + blk14: "░", + block: "█", + srect: "▭", + rect: "▭", + sdot: "⋅", + sdotb: "⊡", + dotsquare: "⊡", + triangle: "▵", + tri: "▵", + trine: "▵", + utri: "▵", + triangledown: "▿", + dtri: "▿", + tridown: "▿", + triangleleft: "◃", + ltri: "◃", + triangleright: "▹", + rtri: "▹", + blacktriangle: "▴", + utrif: "▴", + blacktriangledown: "▾", + dtrif: "▾", + blacktriangleleft: "◂", + ltrif: "◂", + blacktriangleright: "▸", + rtrif: "▸", + loz: "◊", + lozenge: "◊", + blacklozenge: "⧫", + lozf: "⧫", + bigcirc: "◯", + xcirc: "◯", + circ: "ˆ", + Circle: "○", + cir: "○", + o: "○", + bullet: "•", + bull: "•", + hellip: "…", + mldr: "…", + nldr: "‥", + boxh: "─", + HorizontalLine: "─", + boxv: "│", + boxdr: "┌", + boxdl: "┐", + boxur: "└", + boxul: "┘", + boxvr: "├", + boxvl: "┤", + boxhd: "┬", + boxhu: "┴", + boxvh: "┼", + boxH: "═", + boxV: "║", + boxdR: "╒", + boxDr: "╓", + boxDR: "╔", + boxDl: "╕", + boxdL: "╖", + boxDL: "╗", + boxuR: "╘", + boxUr: "╙", + boxUR: "╚", + boxUl: "╜", + boxuL: "╛", + boxUL: "╝", + boxvR: "╞", + boxVr: "╟", + boxVR: "╠", + boxVl: "╢", + boxvL: "╡", + boxVL: "╣", + boxHd: "╤", + boxhD: "╥", + boxHD: "╦", + boxHu: "╧", + boxhU: "╨", + boxHU: "╩", + boxvH: "╪", + boxVh: "╫", + boxVH: "╬" +}; +var PUNCTUATION = { + excl: "!", + iexcl: "¡", + brvbar: "¦", + sect: "§", + uml: "¨", + copy: "©", + ordf: "ª", + laquo: "«", + not: "¬", + shy: "­", + reg: "®", + macr: "¯", + deg: "°", + plusmn: "±", + sup2: "²", + sup3: "³", + acute: "´", + micro: "µ", + para: "¶", + middot: "·", + cedil: "¸", + sup1: "¹", + ordm: "º", + raquo: "»", + frac14: "¼", + frac12: "½", + frac34: "¾", + iquest: "¿", + nbsp: " ", + comma: ",", + period: ".", + colon: ":", + semi: ";", + vert: "|", + Verbar: "‖", + verbar: "|", + dblac: "˝", + circ: "ˆ", + caron: "ˇ", + breve: "˘", + dot: "˙", + ring: "˚", + ogon: "˛", + tilde: "˜", + DiacriticalGrave: "`", + DiacriticalAcute: "´", + DiacriticalTilde: "˜", + DiacriticalDot: "˙", + DiacriticalDoubleAcute: "˝", + grave: "`", + acute: "´" +}; +var CURRENCY = { + cent: "¢", + pound: "£", + curren: "¤", + yen: "¥", + euro: "€", + dollar: "$", + euro: "€", + fnof: "ƒ", + inr: "₹", + af: "؋", + birr: "ብር", + peso: "₱", + rub: "₽", + won: "₩", + yuan: "¥", + cedil: "¸" +}; +var FRACTIONS = { + frac12: "½", + half: "½", + frac13: "⅓", + frac14: "¼", + frac15: "⅕", + frac16: "⅙", + frac18: "⅛", + frac23: "⅔", + frac25: "⅖", + frac34: "¾", + frac35: "⅗", + frac38: "⅜", + frac45: "⅘", + frac56: "⅚", + frac58: "⅝", + frac78: "⅞", + frasl: "⁄" +}; +var MISC_SYMBOLS = { + trade: "™", + TRADE: "™", + telrec: "⌕", + target: "⌖", + ulcorn: "⌜", + ulcorner: "⌜", + urcorn: "⌝", + urcorner: "⌝", + dlcorn: "⌞", + llcorner: "⌞", + drcorn: "⌟", + lrcorner: "⌟", + intercal: "⊺", + intcal: "⊺", + oplus: "⊕", + CirclePlus: "⊕", + ominus: "⊖", + CircleMinus: "⊖", + otimes: "⊗", + CircleTimes: "⊗", + osol: "⊘", + odot: "⊙", + CircleDot: "⊙", + oast: "⊛", + circledast: "⊛", + odash: "⊝", + circleddash: "⊝", + ocirc: "⊚", + circledcirc: "⊚", + boxplus: "⊞", + plusb: "⊞", + boxminus: "⊟", + minusb: "⊟", + boxtimes: "⊠", + timesb: "⊠", + boxdot: "⊡", + sdotb: "⊡", + veebar: "⊻", + vee: "∨", + barvee: "⊽", + and: "∧", + wedge: "∧", + Cap: "⋒", + Cup: "⋓", + Fork: "⋔", + pitchfork: "⋔", + epar: "⋕", + ltlarr: "⥶", + nvap: "≍⃒", + nvsim: "∼⃒", + nvge: "≥⃒", + nvle: "≤⃒", + nvlt: "<⃒", + nvgt: ">⃒", + nvltrie: "⊴⃒", + nvrtrie: "⊵⃒", + Vdash: "⊩", + dashv: "⊣", + vDash: "⊨", + Vdash: "⊩", + Vvdash: "⊪", + nvdash: "⊬", + nvDash: "⊭", + nVdash: "⊮", + nVDash: "⊯" +}; +var ALL_ENTITIES = { + ...BASIC_LATIN, + ...LATIN_ACCENTS, + ...LATIN_EXTENDED, + ...GREEK, + ...CYRILLIC, + ...MATH, + ...MATH_ADVANCED, + ...ARROWS, + ...SHAPES, + ...PUNCTUATION, + ...CURRENCY, + ...FRACTIONS, + ...MISC_SYMBOLS +}; +var XML = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + quot: '"' +}; +var COMMON_HTML = { + nbsp: " ", + copy: "©", + reg: "®", + trade: "™", + mdash: "—", + ndash: "–", + hellip: "…", + laquo: "«", + raquo: "»", + lsquo: "‘", + rsquo: "’", + ldquo: "“", + rdquo: "”", + bull: "•", + para: "¶", + sect: "§", + deg: "°", + frac12: "½", + frac14: "¼", + frac34: "¾" +}; + +// node_modules/@nodable/entities/src/EntityDecoder.js +var SPECIAL_CHARS = new Set("!?\\\\/[]$%{}^&*()<>|+"); +function validateEntityName(name) { + if (name[0] === "#") { + throw new Error(`[EntityReplacer] Invalid character '#' in entity name: "${name}"`); + } + for (const ch of name) { + if (SPECIAL_CHARS.has(ch)) { + throw new Error(`[EntityReplacer] Invalid character '${ch}' in entity name: "${name}"`); + } + } + return name; +} +function mergeEntityMaps(...maps) { + const out = Object.create(null); + for (const map of maps) { + if (!map) + continue; + for (const key of Object.keys(map)) { + const raw = map[key]; + if (typeof raw === "string") { + out[key] = raw; + } else if (raw && typeof raw === "object" && raw.val !== undefined) { + const val = raw.val; + if (typeof val === "string") { + out[key] = val; + } + } + } + } + return out; +} +var LIMIT_TIER_EXTERNAL = "external"; +var LIMIT_TIER_BASE = "base"; +var LIMIT_TIER_ALL = "all"; +function parseLimitTiers(raw) { + if (!raw || raw === LIMIT_TIER_EXTERNAL) + return new Set([LIMIT_TIER_EXTERNAL]); + if (raw === LIMIT_TIER_ALL) + return new Set([LIMIT_TIER_ALL]); + if (raw === LIMIT_TIER_BASE) + return new Set([LIMIT_TIER_BASE]); + if (Array.isArray(raw)) + return new Set(raw); + return new Set([LIMIT_TIER_EXTERNAL]); +} +var NCR_LEVEL = Object.freeze({ allow: 0, leave: 1, remove: 2, throw: 3 }); +var XML10_ALLOWED_C0 = new Set([9, 10, 13]); +function parseNCRConfig(ncr) { + if (!ncr) { + return { xmlVersion: 1, onLevel: NCR_LEVEL.allow, nullLevel: NCR_LEVEL.remove }; + } + const xmlVersion = ncr.xmlVersion === 1.1 ? 1.1 : 1; + const onLevel = NCR_LEVEL[ncr.onNCR] ?? NCR_LEVEL.allow; + const nullLevel = NCR_LEVEL[ncr.nullNCR] ?? NCR_LEVEL.remove; + const clampedNull = Math.max(nullLevel, NCR_LEVEL.remove); + return { xmlVersion, onLevel, nullLevel: clampedNull }; +} + +class EntityDecoder { + constructor(options = {}) { + this._limit = options.limit || {}; + this._maxTotalExpansions = this._limit.maxTotalExpansions || 0; + this._maxExpandedLength = this._limit.maxExpandedLength || 0; + this._postCheck = typeof options.postCheck === "function" ? options.postCheck : (r) => r; + this._limitTiers = parseLimitTiers(this._limit.applyLimitsTo ?? LIMIT_TIER_EXTERNAL); + this._numericAllowed = options.numericAllowed ?? true; + this._baseMap = mergeEntityMaps(XML, options.namedEntities || null); + this._externalMap = Object.create(null); + this._inputMap = Object.create(null); + this._totalExpansions = 0; + this._expandedLength = 0; + this._removeSet = new Set(options.remove && Array.isArray(options.remove) ? options.remove : []); + this._leaveSet = new Set(options.leave && Array.isArray(options.leave) ? options.leave : []); + const ncrCfg = parseNCRConfig(options.ncr); + this._ncrXmlVersion = ncrCfg.xmlVersion; + this._ncrOnLevel = ncrCfg.onLevel; + this._ncrNullLevel = ncrCfg.nullLevel; + } + setExternalEntities(map) { + if (map) { + for (const key of Object.keys(map)) { + validateEntityName(key); + } + } + this._externalMap = mergeEntityMaps(map); + } + addExternalEntity(key, value) { + validateEntityName(key); + if (typeof value === "string" && value.indexOf("&") === -1) { + this._externalMap[key] = value; + } + } + addInputEntities(map) { + this._totalExpansions = 0; + this._expandedLength = 0; + this._inputMap = mergeEntityMaps(map); + } + reset() { + this._inputMap = Object.create(null); + this._totalExpansions = 0; + this._expandedLength = 0; + return this; + } + setXmlVersion(version) { + this._ncrXmlVersion = version === 1.1 ? 1.1 : 1; + } + decode(str) { + if (typeof str !== "string" || str.length === 0) + return str; + const original = str; + const chunks = []; + const len = str.length; + let last = 0; + let i = 0; + const limitExpansions = this._maxTotalExpansions > 0; + const limitLength = this._maxExpandedLength > 0; + const checkLimits = limitExpansions || limitLength; + while (i < len) { + if (str.charCodeAt(i) !== 38) { + i++; + continue; + } + let j = i + 1; + while (j < len && str.charCodeAt(j) !== 59 && j - i <= 32) + j++; + if (j >= len || str.charCodeAt(j) !== 59) { + i++; + continue; + } + const token = str.slice(i + 1, j); + if (token.length === 0) { + i++; + continue; + } + let replacement; + let tier; + if (this._removeSet.has(token)) { + replacement = ""; + if (tier === undefined) { + tier = LIMIT_TIER_EXTERNAL; + } + } else if (this._leaveSet.has(token)) { + i++; + continue; + } else if (token.charCodeAt(0) === 35) { + const ncrResult = this._resolveNCR(token); + if (ncrResult === undefined) { + i++; + continue; + } + replacement = ncrResult; + tier = LIMIT_TIER_BASE; + } else { + const resolved = this._resolveName(token); + replacement = resolved?.value; + tier = resolved?.tier; + } + if (replacement === undefined) { + i++; + continue; + } + if (i > last) + chunks.push(str.slice(last, i)); + chunks.push(replacement); + last = j + 1; + i = last; + if (checkLimits && this._tierCounts(tier)) { + if (limitExpansions) { + this._totalExpansions++; + if (this._totalExpansions > this._maxTotalExpansions) { + throw new Error(`[EntityReplacer] Entity expansion count limit exceeded: ` + `${this._totalExpansions} > ${this._maxTotalExpansions}`); + } + } + if (limitLength) { + const delta = replacement.length - (token.length + 2); + if (delta > 0) { + this._expandedLength += delta; + if (this._expandedLength > this._maxExpandedLength) { + throw new Error(`[EntityReplacer] Expanded content length limit exceeded: ` + `${this._expandedLength} > ${this._maxExpandedLength}`); + } + } + } + } + } + if (last < len) + chunks.push(str.slice(last)); + const result = chunks.length === 0 ? str : chunks.join(""); + return this._postCheck(result, original); + } + _tierCounts(tier) { + if (this._limitTiers.has(LIMIT_TIER_ALL)) + return true; + return this._limitTiers.has(tier); + } + _resolveName(name) { + if (name in this._inputMap) + return { value: this._inputMap[name], tier: LIMIT_TIER_EXTERNAL }; + if (name in this._externalMap) + return { value: this._externalMap[name], tier: LIMIT_TIER_EXTERNAL }; + if (name in this._baseMap) + return { value: this._baseMap[name], tier: LIMIT_TIER_BASE }; + return; + } + _classifyNCR(cp) { + if (cp === 0) + return this._ncrNullLevel; + if (cp >= 55296 && cp <= 57343) + return NCR_LEVEL.remove; + if (this._ncrXmlVersion === 1) { + if (cp >= 1 && cp <= 31 && !XML10_ALLOWED_C0.has(cp)) + return NCR_LEVEL.remove; + } + return -1; + } + _applyNCRAction(action, token, cp) { + switch (action) { + case NCR_LEVEL.allow: + return String.fromCodePoint(cp); + case NCR_LEVEL.remove: + return ""; + case NCR_LEVEL.leave: + return; + case NCR_LEVEL.throw: + throw new Error(`[EntityDecoder] Prohibited numeric character reference ` + `&${token}; (U+${cp.toString(16).toUpperCase().padStart(4, "0")})`); + default: + return String.fromCodePoint(cp); + } + } + _resolveNCR(token) { + const second = token.charCodeAt(1); + let cp; + if (second === 120 || second === 88) { + cp = parseInt(token.slice(2), 16); + } else { + cp = parseInt(token.slice(1), 10); + } + if (Number.isNaN(cp) || cp < 0 || cp > 1114111) + return; + const minimum = this._classifyNCR(cp); + if (!this._numericAllowed && minimum < NCR_LEVEL.remove) + return; + const effective = minimum === -1 ? this._ncrOnLevel : Math.max(this._ncrOnLevel, minimum); + return this._applyNCRAction(effective, token, cp); + } +} +// node_modules/fast-xml-parser/src/xmlparser/OptionsBuilder.js +var defaultOnDangerousProperty = (name) => { + if (DANGEROUS_PROPERTY_NAMES.includes(name)) { + return "__" + name; + } + return name; +}; +var defaultOptions2 = { + preserveOrder: false, + attributeNamePrefix: "@_", + attributesGroupName: false, + textNodeName: "#text", + ignoreAttributes: true, + removeNSPrefix: false, + allowBooleanAttributes: false, + parseTagValue: true, + parseAttributeValue: false, + trimValues: true, + cdataPropName: false, + numberParseOptions: { + hex: true, + leadingZeros: true, + eNotation: true + }, + tagValueProcessor: function(tagName, val) { + return val; + }, + attributeValueProcessor: function(attrName, val) { + return val; + }, + stopNodes: [], + alwaysCreateTextNode: false, + isArray: () => false, + commentPropName: false, + unpairedTags: [], + processEntities: true, + htmlEntities: false, + entityDecoder: null, + ignoreDeclaration: false, + ignorePiTags: false, + transformTagName: false, + transformAttributeName: false, + updateTag: function(tagName, jPath, attrs) { + return tagName; + }, + captureMetaData: false, + maxNestedTags: 100, + strictReservedNames: true, + jPath: true, + onDangerousProperty: defaultOnDangerousProperty +}; +function validatePropertyName(propertyName, optionName) { + if (typeof propertyName !== "string") { + return; + } + const normalized = propertyName.toLowerCase(); + if (DANGEROUS_PROPERTY_NAMES.some((dangerous) => normalized === dangerous.toLowerCase())) { + throw new Error(`[SECURITY] Invalid ${optionName}: "${propertyName}" is a reserved JavaScript keyword that could cause prototype pollution`); + } + if (criticalProperties.some((dangerous) => normalized === dangerous.toLowerCase())) { + throw new Error(`[SECURITY] Invalid ${optionName}: "${propertyName}" is a reserved JavaScript keyword that could cause prototype pollution`); + } +} +function normalizeProcessEntities(value, htmlEntities) { + if (typeof value === "boolean") { + return { + enabled: value, + maxEntitySize: 1e4, + maxExpansionDepth: 1e4, + maxTotalExpansions: Infinity, + maxExpandedLength: 1e5, + maxEntityCount: 1000, + allowedTags: null, + tagFilter: null, + appliesTo: "all" + }; + } + if (typeof value === "object" && value !== null) { + return { + enabled: value.enabled !== false, + maxEntitySize: Math.max(1, value.maxEntitySize ?? 1e4), + maxExpansionDepth: Math.max(1, value.maxExpansionDepth ?? 1e4), + maxTotalExpansions: Math.max(1, value.maxTotalExpansions ?? Infinity), + maxExpandedLength: Math.max(1, value.maxExpandedLength ?? 1e5), + maxEntityCount: Math.max(1, value.maxEntityCount ?? 1000), + allowedTags: value.allowedTags ?? null, + tagFilter: value.tagFilter ?? null, + appliesTo: value.appliesTo ?? "all" + }; + } + return normalizeProcessEntities(true); +} +var buildOptions = function(options) { + const built = Object.assign({}, defaultOptions2, options); + const propertyNameOptions = [ + { value: built.attributeNamePrefix, name: "attributeNamePrefix" }, + { value: built.attributesGroupName, name: "attributesGroupName" }, + { value: built.textNodeName, name: "textNodeName" }, + { value: built.cdataPropName, name: "cdataPropName" }, + { value: built.commentPropName, name: "commentPropName" } + ]; + for (const { value, name } of propertyNameOptions) { + if (value) { + validatePropertyName(value, name); + } + } + if (built.onDangerousProperty === null) { + built.onDangerousProperty = defaultOnDangerousProperty; + } + built.processEntities = normalizeProcessEntities(built.processEntities, built.htmlEntities); + built.unpairedTagsSet = new Set(built.unpairedTags); + if (built.stopNodes && Array.isArray(built.stopNodes)) { + built.stopNodes = built.stopNodes.map((node) => { + if (typeof node === "string" && node.startsWith("*.")) { + return ".." + node.substring(2); + } + return node; + }); + } + return built; +}; + +// node_modules/fast-xml-parser/src/xmlparser/xmlNode.js +var METADATA_SYMBOL; +if (typeof Symbol !== "function") { + METADATA_SYMBOL = "@@xmlMetadata"; +} else { + METADATA_SYMBOL = Symbol("XML Node Metadata"); +} + +class XmlNode { + constructor(tagname) { + this.tagname = tagname; + this.child = []; + this[":@"] = Object.create(null); + } + add(key, val) { + if (key === "__proto__") + key = "#__proto__"; + this.child.push({ [key]: val }); + } + addChild(node, startIndex) { + if (node.tagname === "__proto__") + node.tagname = "#__proto__"; + if (node[":@"] && Object.keys(node[":@"]).length > 0) { + this.child.push({ [node.tagname]: node.child, [":@"]: node[":@"] }); + } else { + this.child.push({ [node.tagname]: node.child }); + } + if (startIndex !== undefined) { + this.child[this.child.length - 1][METADATA_SYMBOL] = { startIndex }; + } + } + static getMetaDataSymbol() { + return METADATA_SYMBOL; + } +} + +// node_modules/fast-xml-parser/src/xmlparser/DocTypeReader.js +class DocTypeReader { + constructor(options) { + this.suppressValidationErr = !options; + this.options = options; + } + readDocType(xmlData, i) { + const entities = Object.create(null); + let entityCount = 0; + if (xmlData[i + 3] === "O" && xmlData[i + 4] === "C" && xmlData[i + 5] === "T" && xmlData[i + 6] === "Y" && xmlData[i + 7] === "P" && xmlData[i + 8] === "E") { + i = i + 9; + let angleBracketsCount = 1; + let hasBody = false, comment = false; + let exp = ""; + for (;i < xmlData.length; i++) { + if (xmlData[i] === "<" && !comment) { + if (hasBody && hasSeq(xmlData, "!ENTITY", i)) { + i += 7; + let entityName, val; + [entityName, val, i] = this.readEntityExp(xmlData, i + 1, this.suppressValidationErr); + if (val.indexOf("&") === -1) { + if (this.options.enabled !== false && this.options.maxEntityCount != null && entityCount >= this.options.maxEntityCount) { + throw new Error(`Entity count (${entityCount + 1}) exceeds maximum allowed (${this.options.maxEntityCount})`); + } + entities[entityName] = val; + entityCount++; + } + } else if (hasBody && hasSeq(xmlData, "!ELEMENT", i)) { + i += 8; + const { index } = this.readElementExp(xmlData, i + 1); + i = index; + } else if (hasBody && hasSeq(xmlData, "!ATTLIST", i)) { + i += 8; + } else if (hasBody && hasSeq(xmlData, "!NOTATION", i)) { + i += 9; + const { index } = this.readNotationExp(xmlData, i + 1, this.suppressValidationErr); + i = index; + } else if (hasSeq(xmlData, "!--", i)) + comment = true; + else + throw new Error(`Invalid DOCTYPE`); + angleBracketsCount++; + exp = ""; + } else if (xmlData[i] === ">") { + if (comment) { + if (xmlData[i - 1] === "-" && xmlData[i - 2] === "-") { + comment = false; + angleBracketsCount--; + } + } else { + angleBracketsCount--; + } + if (angleBracketsCount === 0) { + break; + } + } else if (xmlData[i] === "[") { + hasBody = true; + } else { + exp += xmlData[i]; + } + } + if (angleBracketsCount !== 0) { + throw new Error(`Unclosed DOCTYPE`); + } + } else { + throw new Error(`Invalid Tag instead of DOCTYPE`); + } + return { entities, i }; + } + readEntityExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + const startIndex = i; + while (i < xmlData.length && !/\s/.test(xmlData[i]) && xmlData[i] !== '"' && xmlData[i] !== "'") { + i++; + } + let entityName = xmlData.substring(startIndex, i); + validateEntityName2(entityName); + i = skipWhitespace(xmlData, i); + if (!this.suppressValidationErr) { + if (xmlData.substring(i, i + 6).toUpperCase() === "SYSTEM") { + throw new Error("External entities are not supported"); + } else if (xmlData[i] === "%") { + throw new Error("Parameter entities are not supported"); + } + } + let entityValue = ""; + [i, entityValue] = this.readIdentifierVal(xmlData, i, "entity"); + if (this.options.enabled !== false && this.options.maxEntitySize != null && entityValue.length > this.options.maxEntitySize) { + throw new Error(`Entity "${entityName}" size (${entityValue.length}) exceeds maximum allowed size (${this.options.maxEntitySize})`); + } + i--; + return [entityName, entityValue, i]; + } + readNotationExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + const startIndex = i; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + i++; + } + let notationName = xmlData.substring(startIndex, i); + !this.suppressValidationErr && validateEntityName2(notationName); + i = skipWhitespace(xmlData, i); + const identifierType = xmlData.substring(i, i + 6).toUpperCase(); + if (!this.suppressValidationErr && identifierType !== "SYSTEM" && identifierType !== "PUBLIC") { + throw new Error(`Expected SYSTEM or PUBLIC, found "${identifierType}"`); + } + i += identifierType.length; + i = skipWhitespace(xmlData, i); + let publicIdentifier = null; + let systemIdentifier = null; + if (identifierType === "PUBLIC") { + [i, publicIdentifier] = this.readIdentifierVal(xmlData, i, "publicIdentifier"); + i = skipWhitespace(xmlData, i); + if (xmlData[i] === '"' || xmlData[i] === "'") { + [i, systemIdentifier] = this.readIdentifierVal(xmlData, i, "systemIdentifier"); + } + } else if (identifierType === "SYSTEM") { + [i, systemIdentifier] = this.readIdentifierVal(xmlData, i, "systemIdentifier"); + if (!this.suppressValidationErr && !systemIdentifier) { + throw new Error("Missing mandatory system identifier for SYSTEM notation"); + } + } + return { notationName, publicIdentifier, systemIdentifier, index: --i }; + } + readIdentifierVal(xmlData, i, type) { + let identifierVal = ""; + const startChar = xmlData[i]; + if (startChar !== '"' && startChar !== "'") { + throw new Error(`Expected quoted string, found "${startChar}"`); + } + i++; + const startIndex = i; + while (i < xmlData.length && xmlData[i] !== startChar) { + i++; + } + identifierVal = xmlData.substring(startIndex, i); + if (xmlData[i] !== startChar) { + throw new Error(`Unterminated ${type} value`); + } + i++; + return [i, identifierVal]; + } + readElementExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + const startIndex = i; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + i++; + } + let elementName = xmlData.substring(startIndex, i); + if (!this.suppressValidationErr && !isName(elementName)) { + throw new Error(`Invalid element name: "${elementName}"`); + } + i = skipWhitespace(xmlData, i); + let contentModel = ""; + if (xmlData[i] === "E" && hasSeq(xmlData, "MPTY", i)) + i += 4; + else if (xmlData[i] === "A" && hasSeq(xmlData, "NY", i)) + i += 2; + else if (xmlData[i] === "(") { + i++; + const startIndex = i; + while (i < xmlData.length && xmlData[i] !== ")") { + i++; + } + contentModel = xmlData.substring(startIndex, i); + if (xmlData[i] !== ")") { + throw new Error("Unterminated content model"); + } + } else if (!this.suppressValidationErr) { + throw new Error(`Invalid Element Expression, found "${xmlData[i]}"`); + } + return { + elementName, + contentModel: contentModel.trim(), + index: i + }; + } + readAttlistExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + let startIndex = i; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + i++; + } + let elementName = xmlData.substring(startIndex, i); + validateEntityName2(elementName); + i = skipWhitespace(xmlData, i); + startIndex = i; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + i++; + } + let attributeName = xmlData.substring(startIndex, i); + if (!validateEntityName2(attributeName)) { + throw new Error(`Invalid attribute name: "${attributeName}"`); + } + i = skipWhitespace(xmlData, i); + let attributeType = ""; + if (xmlData.substring(i, i + 8).toUpperCase() === "NOTATION") { + attributeType = "NOTATION"; + i += 8; + i = skipWhitespace(xmlData, i); + if (xmlData[i] !== "(") { + throw new Error(`Expected '(', found "${xmlData[i]}"`); + } + i++; + let allowedNotations = []; + while (i < xmlData.length && xmlData[i] !== ")") { + const startIndex = i; + while (i < xmlData.length && xmlData[i] !== "|" && xmlData[i] !== ")") { + i++; + } + let notation = xmlData.substring(startIndex, i); + notation = notation.trim(); + if (!validateEntityName2(notation)) { + throw new Error(`Invalid notation name: "${notation}"`); + } + allowedNotations.push(notation); + if (xmlData[i] === "|") { + i++; + i = skipWhitespace(xmlData, i); + } + } + if (xmlData[i] !== ")") { + throw new Error("Unterminated list of notations"); + } + i++; + attributeType += " (" + allowedNotations.join("|") + ")"; + } else { + const startIndex = i; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + i++; + } + attributeType += xmlData.substring(startIndex, i); + const validTypes = ["CDATA", "ID", "IDREF", "IDREFS", "ENTITY", "ENTITIES", "NMTOKEN", "NMTOKENS"]; + if (!this.suppressValidationErr && !validTypes.includes(attributeType.toUpperCase())) { + throw new Error(`Invalid attribute type: "${attributeType}"`); + } + } + i = skipWhitespace(xmlData, i); + let defaultValue = ""; + if (xmlData.substring(i, i + 8).toUpperCase() === "#REQUIRED") { + defaultValue = "#REQUIRED"; + i += 8; + } else if (xmlData.substring(i, i + 7).toUpperCase() === "#IMPLIED") { + defaultValue = "#IMPLIED"; + i += 7; + } else { + [i, defaultValue] = this.readIdentifierVal(xmlData, i, "ATTLIST"); + } + return { + elementName, + attributeName, + attributeType, + defaultValue, + index: i + }; + } +} +var skipWhitespace = (data, index) => { + while (index < data.length && /\s/.test(data[index])) { + index++; + } + return index; +}; +function hasSeq(data, seq, i) { + for (let j = 0;j < seq.length; j++) { + if (seq[j] !== data[i + j + 1]) + return false; + } + return true; +} +function validateEntityName2(name) { + if (isName(name)) + return name; + else + throw new Error(`Invalid entity name ${name}`); +} + +// node_modules/strnum/strnum.js +var hexRegex = /^[-+]?0x[a-fA-F0-9]+$/; +var numRegex = /^([\-\+])?(0*)([0-9]*(\.[0-9]*)?)$/; +var consider = { + hex: true, + leadingZeros: true, + decimalPoint: ".", + eNotation: true, + infinity: "original" +}; +function toNumber(str, options = {}) { + options = Object.assign({}, consider, options); + if (!str || typeof str !== "string") + return str; + let trimmedStr = str.trim(); + if (trimmedStr.length === 0) + return str; + else if (options.skipLike !== undefined && options.skipLike.test(trimmedStr)) + return str; + else if (trimmedStr === "0") + return 0; + else if (options.hex && hexRegex.test(trimmedStr)) { + return parse_int(trimmedStr, 16); + } else if (!isFinite(trimmedStr)) { + return handleInfinity(str, Number(trimmedStr), options); + } else if (trimmedStr.includes("e") || trimmedStr.includes("E")) { + return resolveEnotation(str, trimmedStr, options); + } else { + const match = numRegex.exec(trimmedStr); + if (match) { + const sign = match[1] || ""; + const leadingZeros = match[2]; + let numTrimmedByZeros = trimZeros(match[3]); + const decimalAdjacentToLeadingZeros = sign ? str[leadingZeros.length + 1] === "." : str[leadingZeros.length] === "."; + if (!options.leadingZeros && (leadingZeros.length > 1 || leadingZeros.length === 1 && !decimalAdjacentToLeadingZeros)) { + return str; + } else { + const num = Number(trimmedStr); + const parsedStr = String(num); + if (num === 0) + return num; + if (parsedStr.search(/[eE]/) !== -1) { + if (options.eNotation) + return num; + else + return str; + } else if (trimmedStr.indexOf(".") !== -1) { + if (parsedStr === "0") + return num; + else if (parsedStr === numTrimmedByZeros) + return num; + else if (parsedStr === `${sign}${numTrimmedByZeros}`) + return num; + else + return str; + } + let n = leadingZeros ? numTrimmedByZeros : trimmedStr; + if (leadingZeros) { + return n === parsedStr || sign + n === parsedStr ? num : str; + } else { + return n === parsedStr || n === sign + parsedStr ? num : str; + } + } + } else { + return str; + } + } +} +var eNotationRegx = /^([-+])?(0*)(\d*(\.\d*)?[eE][-\+]?\d+)$/; +function resolveEnotation(str, trimmedStr, options) { + if (!options.eNotation) + return str; + const notation = trimmedStr.match(eNotationRegx); + if (notation) { + let sign = notation[1] || ""; + const eChar = notation[3].indexOf("e") === -1 ? "E" : "e"; + const leadingZeros = notation[2]; + const eAdjacentToLeadingZeros = sign ? str[leadingZeros.length + 1] === eChar : str[leadingZeros.length] === eChar; + if (leadingZeros.length > 1 && eAdjacentToLeadingZeros) + return str; + else if (leadingZeros.length === 1 && (notation[3].startsWith(`.${eChar}`) || notation[3][0] === eChar)) { + return Number(trimmedStr); + } else if (leadingZeros.length > 0) { + if (options.leadingZeros && !eAdjacentToLeadingZeros) { + trimmedStr = (notation[1] || "") + notation[3]; + return Number(trimmedStr); + } else + return str; + } else { + return Number(trimmedStr); + } + } else { + return str; + } +} +function trimZeros(numStr) { + if (numStr && numStr.indexOf(".") !== -1) { + numStr = numStr.replace(/0+$/, ""); + if (numStr === ".") + numStr = "0"; + else if (numStr[0] === ".") + numStr = "0" + numStr; + else if (numStr[numStr.length - 1] === ".") + numStr = numStr.substring(0, numStr.length - 1); + return numStr; + } + return numStr; +} +function parse_int(numStr, base) { + if (parseInt) + return parseInt(numStr, base); + else if (Number.parseInt) + return Number.parseInt(numStr, base); + else if (window && window.parseInt) + return window.parseInt(numStr, base); + else + throw new Error("parseInt, Number.parseInt, window.parseInt are not supported"); +} +function handleInfinity(str, num, options) { + const isPositive = num === Infinity; + switch (options.infinity.toLowerCase()) { + case "null": + return null; + case "infinity": + return num; + case "string": + return isPositive ? "Infinity" : "-Infinity"; + case "original": + default: + return str; + } +} + +// node_modules/fast-xml-parser/src/ignoreAttributes.js +function getIgnoreAttributesFn(ignoreAttributes) { + if (typeof ignoreAttributes === "function") { + return ignoreAttributes; + } + if (Array.isArray(ignoreAttributes)) { + return (attrName) => { + for (const pattern of ignoreAttributes) { + if (typeof pattern === "string" && attrName === pattern) { + return true; + } + if (pattern instanceof RegExp && pattern.test(attrName)) { + return true; + } + } + }; + } + return () => false; +} + +// node_modules/path-expression-matcher/src/Expression.js +class Expression { + constructor(pattern, options = {}, data) { + this.pattern = pattern; + this.separator = options.separator || "."; + this.segments = this._parse(pattern); + this.data = data; + this._hasDeepWildcard = this.segments.some((seg) => seg.type === "deep-wildcard"); + this._hasAttributeCondition = this.segments.some((seg) => seg.attrName !== undefined); + this._hasPositionSelector = this.segments.some((seg) => seg.position !== undefined); + } + _parse(pattern) { + const segments = []; + let i = 0; + let currentPart = ""; + while (i < pattern.length) { + if (pattern[i] === this.separator) { + if (i + 1 < pattern.length && pattern[i + 1] === this.separator) { + if (currentPart.trim()) { + segments.push(this._parseSegment(currentPart.trim())); + currentPart = ""; + } + segments.push({ type: "deep-wildcard" }); + i += 2; + } else { + if (currentPart.trim()) { + segments.push(this._parseSegment(currentPart.trim())); + } + currentPart = ""; + i++; + } + } else { + currentPart += pattern[i]; + i++; + } + } + if (currentPart.trim()) { + segments.push(this._parseSegment(currentPart.trim())); + } + return segments; + } + _parseSegment(part) { + const segment = { type: "tag" }; + let bracketContent = null; + let withoutBrackets = part; + const bracketMatch = part.match(/^([^\[]+)(\[[^\]]*\])(.*)$/); + if (bracketMatch) { + withoutBrackets = bracketMatch[1] + bracketMatch[3]; + if (bracketMatch[2]) { + const content = bracketMatch[2].slice(1, -1); + if (content) { + bracketContent = content; + } + } + } + let namespace = undefined; + let tagAndPosition = withoutBrackets; + if (withoutBrackets.includes("::")) { + const nsIndex = withoutBrackets.indexOf("::"); + namespace = withoutBrackets.substring(0, nsIndex).trim(); + tagAndPosition = withoutBrackets.substring(nsIndex + 2).trim(); + if (!namespace) { + throw new Error(`Invalid namespace in pattern: ${part}`); + } + } + let tag = undefined; + let positionMatch = null; + if (tagAndPosition.includes(":")) { + const colonIndex = tagAndPosition.lastIndexOf(":"); + const tagPart = tagAndPosition.substring(0, colonIndex).trim(); + const posPart = tagAndPosition.substring(colonIndex + 1).trim(); + const isPositionKeyword = ["first", "last", "odd", "even"].includes(posPart) || /^nth\(\d+\)$/.test(posPart); + if (isPositionKeyword) { + tag = tagPart; + positionMatch = posPart; + } else { + tag = tagAndPosition; + } + } else { + tag = tagAndPosition; + } + if (!tag) { + throw new Error(`Invalid segment pattern: ${part}`); + } + segment.tag = tag; + if (namespace) { + segment.namespace = namespace; + } + if (bracketContent) { + if (bracketContent.includes("=")) { + const eqIndex = bracketContent.indexOf("="); + segment.attrName = bracketContent.substring(0, eqIndex).trim(); + segment.attrValue = bracketContent.substring(eqIndex + 1).trim(); + } else { + segment.attrName = bracketContent.trim(); + } + } + if (positionMatch) { + const nthMatch = positionMatch.match(/^nth\((\d+)\)$/); + if (nthMatch) { + segment.position = "nth"; + segment.positionValue = parseInt(nthMatch[1], 10); + } else { + segment.position = positionMatch; + } + } + return segment; + } + get length() { + return this.segments.length; + } + hasDeepWildcard() { + return this._hasDeepWildcard; + } + hasAttributeCondition() { + return this._hasAttributeCondition; + } + hasPositionSelector() { + return this._hasPositionSelector; + } + toString() { + return this.pattern; + } +} + +// node_modules/path-expression-matcher/src/ExpressionSet.js +class ExpressionSet { + constructor() { + this._byDepthAndTag = new Map; + this._wildcardByDepth = new Map; + this._deepWildcards = []; + this._patterns = new Set; + this._sealed = false; + } + add(expression) { + if (this._sealed) { + throw new TypeError("ExpressionSet is sealed. Create a new ExpressionSet to add more expressions."); + } + if (this._patterns.has(expression.pattern)) + return this; + this._patterns.add(expression.pattern); + if (expression.hasDeepWildcard()) { + this._deepWildcards.push(expression); + return this; + } + const depth = expression.length; + const lastSeg = expression.segments[expression.segments.length - 1]; + const tag = lastSeg?.tag; + if (!tag || tag === "*") { + if (!this._wildcardByDepth.has(depth)) + this._wildcardByDepth.set(depth, []); + this._wildcardByDepth.get(depth).push(expression); + } else { + const key = `${depth}:${tag}`; + if (!this._byDepthAndTag.has(key)) + this._byDepthAndTag.set(key, []); + this._byDepthAndTag.get(key).push(expression); + } + return this; + } + addAll(expressions) { + for (const expr of expressions) + this.add(expr); + return this; + } + has(expression) { + return this._patterns.has(expression.pattern); + } + get size() { + return this._patterns.size; + } + seal() { + this._sealed = true; + return this; + } + get isSealed() { + return this._sealed; + } + matchesAny(matcher) { + return this.findMatch(matcher) !== null; + } + findMatch(matcher) { + const depth = matcher.getDepth(); + const tag = matcher.getCurrentTag(); + const exactKey = `${depth}:${tag}`; + const exactBucket = this._byDepthAndTag.get(exactKey); + if (exactBucket) { + for (let i = 0;i < exactBucket.length; i++) { + if (matcher.matches(exactBucket[i])) + return exactBucket[i]; + } + } + const wildcardBucket = this._wildcardByDepth.get(depth); + if (wildcardBucket) { + for (let i = 0;i < wildcardBucket.length; i++) { + if (matcher.matches(wildcardBucket[i])) + return wildcardBucket[i]; + } + } + for (let i = 0;i < this._deepWildcards.length; i++) { + if (matcher.matches(this._deepWildcards[i])) + return this._deepWildcards[i]; + } + return null; + } +} + +// node_modules/path-expression-matcher/src/Matcher.js +class MatcherView { + constructor(matcher) { + this._matcher = matcher; + } + get separator() { + return this._matcher.separator; + } + getCurrentTag() { + const path = this._matcher.path; + return path.length > 0 ? path[path.length - 1].tag : undefined; + } + getCurrentNamespace() { + const path = this._matcher.path; + return path.length > 0 ? path[path.length - 1].namespace : undefined; + } + getAttrValue(attrName) { + const path = this._matcher.path; + if (path.length === 0) + return; + return path[path.length - 1].values?.[attrName]; + } + hasAttr(attrName) { + const path = this._matcher.path; + if (path.length === 0) + return false; + const current = path[path.length - 1]; + return current.values !== undefined && attrName in current.values; + } + getPosition() { + const path = this._matcher.path; + if (path.length === 0) + return -1; + return path[path.length - 1].position ?? 0; + } + getCounter() { + const path = this._matcher.path; + if (path.length === 0) + return -1; + return path[path.length - 1].counter ?? 0; + } + getIndex() { + return this.getPosition(); + } + getDepth() { + return this._matcher.path.length; + } + toString(separator, includeNamespace = true) { + return this._matcher.toString(separator, includeNamespace); + } + toArray() { + return this._matcher.path.map((n) => n.tag); + } + matches(expression) { + return this._matcher.matches(expression); + } + matchesAny(exprSet) { + return exprSet.matchesAny(this._matcher); + } +} + +class Matcher { + constructor(options = {}) { + this.separator = options.separator || "."; + this.path = []; + this.siblingStacks = []; + this._pathStringCache = null; + this._view = new MatcherView(this); + } + push(tagName, attrValues = null, namespace = null) { + this._pathStringCache = null; + if (this.path.length > 0) { + this.path[this.path.length - 1].values = undefined; + } + const currentLevel = this.path.length; + if (!this.siblingStacks[currentLevel]) { + this.siblingStacks[currentLevel] = new Map; + } + const siblings = this.siblingStacks[currentLevel]; + const siblingKey = namespace ? `${namespace}:${tagName}` : tagName; + const counter = siblings.get(siblingKey) || 0; + let position = 0; + for (const count of siblings.values()) { + position += count; + } + siblings.set(siblingKey, counter + 1); + const node = { + tag: tagName, + position, + counter + }; + if (namespace !== null && namespace !== undefined) { + node.namespace = namespace; + } + if (attrValues !== null && attrValues !== undefined) { + node.values = attrValues; + } + this.path.push(node); + } + pop() { + if (this.path.length === 0) + return; + this._pathStringCache = null; + const node = this.path.pop(); + if (this.siblingStacks.length > this.path.length + 1) { + this.siblingStacks.length = this.path.length + 1; + } + return node; + } + updateCurrent(attrValues) { + if (this.path.length > 0) { + const current = this.path[this.path.length - 1]; + if (attrValues !== null && attrValues !== undefined) { + current.values = attrValues; + } + } + } + getCurrentTag() { + return this.path.length > 0 ? this.path[this.path.length - 1].tag : undefined; + } + getCurrentNamespace() { + return this.path.length > 0 ? this.path[this.path.length - 1].namespace : undefined; + } + getAttrValue(attrName) { + if (this.path.length === 0) + return; + return this.path[this.path.length - 1].values?.[attrName]; + } + hasAttr(attrName) { + if (this.path.length === 0) + return false; + const current = this.path[this.path.length - 1]; + return current.values !== undefined && attrName in current.values; + } + getPosition() { + if (this.path.length === 0) + return -1; + return this.path[this.path.length - 1].position ?? 0; + } + getCounter() { + if (this.path.length === 0) + return -1; + return this.path[this.path.length - 1].counter ?? 0; + } + getIndex() { + return this.getPosition(); + } + getDepth() { + return this.path.length; + } + toString(separator, includeNamespace = true) { + const sep = separator || this.separator; + const isDefault = sep === this.separator && includeNamespace === true; + if (isDefault) { + if (this._pathStringCache !== null) { + return this._pathStringCache; + } + const result = this.path.map((n) => n.namespace ? `${n.namespace}:${n.tag}` : n.tag).join(sep); + this._pathStringCache = result; + return result; + } + return this.path.map((n) => includeNamespace && n.namespace ? `${n.namespace}:${n.tag}` : n.tag).join(sep); + } + toArray() { + return this.path.map((n) => n.tag); + } + reset() { + this._pathStringCache = null; + this.path = []; + this.siblingStacks = []; + } + matches(expression) { + const segments = expression.segments; + if (segments.length === 0) { + return false; + } + if (expression.hasDeepWildcard()) { + return this._matchWithDeepWildcard(segments); + } + return this._matchSimple(segments); + } + _matchSimple(segments) { + if (this.path.length !== segments.length) { + return false; + } + for (let i = 0;i < segments.length; i++) { + if (!this._matchSegment(segments[i], this.path[i], i === this.path.length - 1)) { + return false; + } + } + return true; + } + _matchWithDeepWildcard(segments) { + let pathIdx = this.path.length - 1; + let segIdx = segments.length - 1; + while (segIdx >= 0 && pathIdx >= 0) { + const segment = segments[segIdx]; + if (segment.type === "deep-wildcard") { + segIdx--; + if (segIdx < 0) { + return true; + } + const nextSeg = segments[segIdx]; + let found = false; + for (let i = pathIdx;i >= 0; i--) { + if (this._matchSegment(nextSeg, this.path[i], i === this.path.length - 1)) { + pathIdx = i - 1; + segIdx--; + found = true; + break; + } + } + if (!found) { + return false; + } + } else { + if (!this._matchSegment(segment, this.path[pathIdx], pathIdx === this.path.length - 1)) { + return false; + } + pathIdx--; + segIdx--; + } + } + return segIdx < 0; + } + _matchSegment(segment, node, isCurrentNode) { + if (segment.tag !== "*" && segment.tag !== node.tag) { + return false; + } + if (segment.namespace !== undefined) { + if (segment.namespace !== "*" && segment.namespace !== node.namespace) { + return false; + } + } + if (segment.attrName !== undefined) { + if (!isCurrentNode) { + return false; + } + if (!node.values || !(segment.attrName in node.values)) { + return false; + } + if (segment.attrValue !== undefined) { + if (String(node.values[segment.attrName]) !== String(segment.attrValue)) { + return false; + } + } + } + if (segment.position !== undefined) { + if (!isCurrentNode) { + return false; + } + const counter = node.counter ?? 0; + if (segment.position === "first" && counter !== 0) { + return false; + } else if (segment.position === "odd" && counter % 2 !== 1) { + return false; + } else if (segment.position === "even" && counter % 2 !== 0) { + return false; + } else if (segment.position === "nth" && counter !== segment.positionValue) { + return false; + } + } + return true; + } + matchesAny(exprSet) { + return exprSet.matchesAny(this); + } + snapshot() { + return { + path: this.path.map((node) => ({ ...node })), + siblingStacks: this.siblingStacks.map((map) => new Map(map)) + }; + } + restore(snapshot) { + this._pathStringCache = null; + this.path = snapshot.path.map((node) => ({ ...node })); + this.siblingStacks = snapshot.siblingStacks.map((map) => new Map(map)); + } + readOnly() { + return this._view; + } +} + +// node_modules/fast-xml-parser/src/xmlparser/OrderedObjParser.js +function extractRawAttributes(prefixedAttrs, options) { + if (!prefixedAttrs) + return {}; + const attrs = options.attributesGroupName ? prefixedAttrs[options.attributesGroupName] : prefixedAttrs; + if (!attrs) + return {}; + const rawAttrs = {}; + for (const key in attrs) { + if (key.startsWith(options.attributeNamePrefix)) { + const rawName = key.substring(options.attributeNamePrefix.length); + rawAttrs[rawName] = attrs[key]; + } else { + rawAttrs[key] = attrs[key]; + } + } + return rawAttrs; +} +function extractNamespace(rawTagName) { + if (!rawTagName || typeof rawTagName !== "string") + return; + const colonIndex = rawTagName.indexOf(":"); + if (colonIndex !== -1 && colonIndex > 0) { + const ns = rawTagName.substring(0, colonIndex); + if (ns !== "xmlns") { + return ns; + } + } + return; +} + +class OrderedObjParser { + constructor(options, externalEntities) { + this.options = options; + this.currentNode = null; + this.tagsNodeStack = []; + this.parseXml = parseXml; + this.parseTextData = parseTextData; + this.resolveNameSpace = resolveNameSpace; + this.buildAttributesMap = buildAttributesMap; + this.isItStopNode = isItStopNode; + this.replaceEntitiesValue = replaceEntitiesValue; + this.readStopNodeData = readStopNodeData; + this.saveTextToParentTag = saveTextToParentTag; + this.addChild = addChild; + this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes); + this.entityExpansionCount = 0; + this.currentExpandedLength = 0; + let namedEntities = { ...XML }; + if (this.options.entityDecoder) { + this.entityDecoder = this.options.entityDecoder; + } else { + if (typeof this.options.htmlEntities === "object") + namedEntities = this.options.htmlEntities; + else if (this.options.htmlEntities === true) + namedEntities = { ...COMMON_HTML, ...CURRENCY }; + this.entityDecoder = new EntityDecoder({ + namedEntities: { ...namedEntities, ...externalEntities }, + numericAllowed: this.options.htmlEntities, + limit: { + maxTotalExpansions: this.options.processEntities.maxTotalExpansions, + maxExpandedLength: this.options.processEntities.maxExpandedLength, + applyLimitsTo: this.options.processEntities.appliesTo + } + }); + } + this.matcher = new Matcher; + this.readonlyMatcher = this.matcher.readOnly(); + this.isCurrentNodeStopNode = false; + this.stopNodeExpressionsSet = new ExpressionSet; + const stopNodesOpts = this.options.stopNodes; + if (stopNodesOpts && stopNodesOpts.length > 0) { + for (let i = 0;i < stopNodesOpts.length; i++) { + const stopNodeExp = stopNodesOpts[i]; + if (typeof stopNodeExp === "string") { + this.stopNodeExpressionsSet.add(new Expression(stopNodeExp)); + } else if (stopNodeExp instanceof Expression) { + this.stopNodeExpressionsSet.add(stopNodeExp); + } + } + this.stopNodeExpressionsSet.seal(); + } + } +} +function parseTextData(val, tagName, jPath, dontTrim, hasAttributes, isLeafNode, escapeEntities) { + const options = this.options; + if (val !== undefined) { + if (options.trimValues && !dontTrim) { + val = val.trim(); + } + if (val.length > 0) { + if (!escapeEntities) + val = this.replaceEntitiesValue(val, tagName, jPath); + const jPathOrMatcher = options.jPath ? jPath.toString() : jPath; + const newval = options.tagValueProcessor(tagName, val, jPathOrMatcher, hasAttributes, isLeafNode); + if (newval === null || newval === undefined) { + return val; + } else if (typeof newval !== typeof val || newval !== val) { + return newval; + } else if (options.trimValues) { + return parseValue(val, options.parseTagValue, options.numberParseOptions); + } else { + const trimmedVal = val.trim(); + if (trimmedVal === val) { + return parseValue(val, options.parseTagValue, options.numberParseOptions); + } else { + return val; + } + } + } + } +} +function resolveNameSpace(tagname) { + if (this.options.removeNSPrefix) { + const tags = tagname.split(":"); + const prefix = tagname.charAt(0) === "/" ? "/" : ""; + if (tags[0] === "xmlns") { + return ""; + } + if (tags.length === 2) { + tagname = prefix + tags[1]; + } + } + return tagname; +} +var attrsRegx = new RegExp(`([^\\s=]+)\\s*(=\\s*(['"])([\\s\\S]*?)\\3)?`, "gm"); +function buildAttributesMap(attrStr, jPath, tagName, force = false) { + const options = this.options; + if (force === true || options.ignoreAttributes !== true && typeof attrStr === "string") { + const matches = getAllMatches(attrStr, attrsRegx); + const len = matches.length; + const attrs = {}; + const processedVals = new Array(len); + let hasRawAttrs = false; + const rawAttrsForMatcher = {}; + for (let i = 0;i < len; i++) { + const attrName = this.resolveNameSpace(matches[i][1]); + const oldVal = matches[i][4]; + if (attrName.length && oldVal !== undefined) { + let val = oldVal; + if (options.trimValues) + val = val.trim(); + val = this.replaceEntitiesValue(val, tagName, this.readonlyMatcher); + processedVals[i] = val; + rawAttrsForMatcher[attrName] = val; + hasRawAttrs = true; + } + } + if (hasRawAttrs && typeof jPath === "object" && jPath.updateCurrent) { + jPath.updateCurrent(rawAttrsForMatcher); + } + const jPathStr = options.jPath ? jPath.toString() : this.readonlyMatcher; + let hasAttrs = false; + for (let i = 0;i < len; i++) { + const attrName = this.resolveNameSpace(matches[i][1]); + if (this.ignoreAttributesFn(attrName, jPathStr)) + continue; + let aName = options.attributeNamePrefix + attrName; + if (attrName.length) { + if (options.transformAttributeName) { + aName = options.transformAttributeName(aName); + } + aName = sanitizeName(aName, options); + if (matches[i][4] !== undefined) { + const oldVal = processedVals[i]; + const newVal = options.attributeValueProcessor(attrName, oldVal, jPathStr); + if (newVal === null || newVal === undefined) { + attrs[aName] = oldVal; + } else if (typeof newVal !== typeof oldVal || newVal !== oldVal) { + attrs[aName] = newVal; + } else { + attrs[aName] = parseValue(oldVal, options.parseAttributeValue, options.numberParseOptions); + } + hasAttrs = true; + } else if (options.allowBooleanAttributes) { + attrs[aName] = true; + hasAttrs = true; + } + } + } + if (!hasAttrs) + return; + if (options.attributesGroupName && !options.preserveOrder) { + const attrCollection = {}; + attrCollection[options.attributesGroupName] = attrs; + return attrCollection; + } + return attrs; + } +} +var parseXml = function(xmlData) { + xmlData = xmlData.replace(/\r\n?/g, ` +`); + const xmlObj = new XmlNode("!xml"); + let currentNode = xmlObj; + let textData = ""; + this.matcher.reset(); + this.entityDecoder.reset(); + this.entityExpansionCount = 0; + this.currentExpandedLength = 0; + const options = this.options; + const docTypeReader = new DocTypeReader(options.processEntities); + const xmlLen = xmlData.length; + for (let i = 0;i < xmlLen; i++) { + const ch = xmlData[i]; + if (ch === "<") { + const c1 = xmlData.charCodeAt(i + 1); + if (c1 === 47) { + const closeIndex = findClosingIndex(xmlData, ">", i, "Closing Tag is not closed."); + let tagName = xmlData.substring(i + 2, closeIndex).trim(); + if (options.removeNSPrefix) { + const colonIndex = tagName.indexOf(":"); + if (colonIndex !== -1) { + tagName = tagName.substr(colonIndex + 1); + } + } + tagName = transformTagName(options.transformTagName, tagName, "", options).tagName; + if (currentNode) { + textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher); + } + const lastTagName = this.matcher.getCurrentTag(); + if (tagName && options.unpairedTagsSet.has(tagName)) { + throw new Error(`Unpaired tag can not be used as closing tag: `); + } + if (lastTagName && options.unpairedTagsSet.has(lastTagName)) { + this.matcher.pop(); + this.tagsNodeStack.pop(); + } + this.matcher.pop(); + this.isCurrentNodeStopNode = false; + currentNode = this.tagsNodeStack.pop(); + textData = ""; + i = closeIndex; + } else if (c1 === 63) { + let tagData = readTagExp(xmlData, i, false, "?>"); + if (!tagData) + throw new Error("Pi Tag is not closed."); + textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher); + const attsMap = this.buildAttributesMap(tagData.tagExp, this.matcher, tagData.tagName, true); + if (attsMap) { + const ver = attsMap[this.options.attributeNamePrefix + "version"]; + this.entityDecoder.setXmlVersion(Number(ver) || 1); + } + if (options.ignoreDeclaration && tagData.tagName === "?xml" || options.ignorePiTags) {} else { + const childNode = new XmlNode(tagData.tagName); + childNode.add(options.textNodeName, ""); + if (tagData.tagName !== tagData.tagExp && tagData.attrExpPresent && options.ignoreAttributes !== true) { + childNode[":@"] = attsMap; + } + this.addChild(currentNode, childNode, this.readonlyMatcher, i); + } + i = tagData.closeIndex + 1; + } else if (c1 === 33 && xmlData.charCodeAt(i + 2) === 45 && xmlData.charCodeAt(i + 3) === 45) { + const endIndex = findClosingIndex(xmlData, "-->", i + 4, "Comment is not closed."); + if (options.commentPropName) { + const comment = xmlData.substring(i + 4, endIndex - 2); + textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher); + currentNode.add(options.commentPropName, [{ [options.textNodeName]: comment }]); + } + i = endIndex; + } else if (c1 === 33 && xmlData.charCodeAt(i + 2) === 68) { + const result = docTypeReader.readDocType(xmlData, i); + this.entityDecoder.addInputEntities(result.entities); + i = result.i; + } else if (c1 === 33 && xmlData.charCodeAt(i + 2) === 91) { + const closeIndex = findClosingIndex(xmlData, "]]>", i, "CDATA is not closed.") - 2; + const tagExp = xmlData.substring(i + 9, closeIndex); + textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher); + let val = this.parseTextData(tagExp, currentNode.tagname, this.readonlyMatcher, true, false, true, true); + if (val == undefined) + val = ""; + if (options.cdataPropName) { + currentNode.add(options.cdataPropName, [{ [options.textNodeName]: tagExp }]); + } else { + currentNode.add(options.textNodeName, val); + } + i = closeIndex + 2; + } else { + let result = readTagExp(xmlData, i, options.removeNSPrefix); + if (!result) { + const context = xmlData.substring(Math.max(0, i - 50), Math.min(xmlLen, i + 50)); + throw new Error(`readTagExp returned undefined at position ${i}. Context: "${context}"`); + } + let tagName = result.tagName; + const rawTagName = result.rawTagName; + let tagExp = result.tagExp; + let attrExpPresent = result.attrExpPresent; + let closeIndex = result.closeIndex; + ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options)); + if (options.strictReservedNames && (tagName === options.commentPropName || tagName === options.cdataPropName || tagName === options.textNodeName || tagName === options.attributesGroupName)) { + throw new Error(`Invalid tag name: ${tagName}`); + } + if (currentNode && textData) { + if (currentNode.tagname !== "!xml") { + textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher, false); + } + } + const lastTag = currentNode; + if (lastTag && options.unpairedTagsSet.has(lastTag.tagname)) { + currentNode = this.tagsNodeStack.pop(); + this.matcher.pop(); + } + let isSelfClosing = false; + if (tagExp.length > 0 && tagExp.lastIndexOf("/") === tagExp.length - 1) { + isSelfClosing = true; + if (tagName[tagName.length - 1] === "/") { + tagName = tagName.substr(0, tagName.length - 1); + tagExp = tagName; + } else { + tagExp = tagExp.substr(0, tagExp.length - 1); + } + attrExpPresent = tagName !== tagExp; + } + let prefixedAttrs = null; + let rawAttrs = {}; + let namespace = undefined; + namespace = extractNamespace(rawTagName); + if (tagName !== xmlObj.tagname) { + this.matcher.push(tagName, {}, namespace); + } + if (tagName !== tagExp && attrExpPresent) { + prefixedAttrs = this.buildAttributesMap(tagExp, this.matcher, tagName); + if (prefixedAttrs) { + rawAttrs = extractRawAttributes(prefixedAttrs, options); + } + } + if (tagName !== xmlObj.tagname) { + this.isCurrentNodeStopNode = this.isItStopNode(); + } + const startIndex = i; + if (this.isCurrentNodeStopNode) { + let tagContent = ""; + if (isSelfClosing) { + i = result.closeIndex; + } else if (options.unpairedTagsSet.has(tagName)) { + i = result.closeIndex; + } else { + const result = this.readStopNodeData(xmlData, rawTagName, closeIndex + 1); + if (!result) + throw new Error(`Unexpected end of ${rawTagName}`); + i = result.i; + tagContent = result.tagContent; + } + const childNode = new XmlNode(tagName); + if (prefixedAttrs) { + childNode[":@"] = prefixedAttrs; + } + childNode.add(options.textNodeName, tagContent); + this.matcher.pop(); + this.isCurrentNodeStopNode = false; + this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); + } else { + if (isSelfClosing) { + ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options)); + const childNode = new XmlNode(tagName); + if (prefixedAttrs) { + childNode[":@"] = prefixedAttrs; + } + this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); + this.matcher.pop(); + this.isCurrentNodeStopNode = false; + } else if (options.unpairedTagsSet.has(tagName)) { + const childNode = new XmlNode(tagName); + if (prefixedAttrs) { + childNode[":@"] = prefixedAttrs; + } + this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); + this.matcher.pop(); + this.isCurrentNodeStopNode = false; + i = result.closeIndex; + continue; + } else { + const childNode = new XmlNode(tagName); + if (this.tagsNodeStack.length > options.maxNestedTags) { + throw new Error("Maximum nested tags exceeded"); + } + this.tagsNodeStack.push(currentNode); + if (prefixedAttrs) { + childNode[":@"] = prefixedAttrs; + } + this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex); + currentNode = childNode; + } + textData = ""; + i = closeIndex; + } + } + } else { + textData += xmlData[i]; + } + } + return xmlObj.child; +}; +function addChild(currentNode, childNode, matcher, startIndex) { + if (!this.options.captureMetaData) + startIndex = undefined; + const jPathOrMatcher = this.options.jPath ? matcher.toString() : matcher; + const result = this.options.updateTag(childNode.tagname, jPathOrMatcher, childNode[":@"]); + if (result === false) {} else if (typeof result === "string") { + childNode.tagname = result; + currentNode.addChild(childNode, startIndex); + } else { + currentNode.addChild(childNode, startIndex); + } +} +function replaceEntitiesValue(val, tagName, jPath) { + const entityConfig = this.options.processEntities; + if (!entityConfig || !entityConfig.enabled) { + return val; + } + if (entityConfig.allowedTags) { + const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath; + const allowed = Array.isArray(entityConfig.allowedTags) ? entityConfig.allowedTags.includes(tagName) : entityConfig.allowedTags(tagName, jPathOrMatcher); + if (!allowed) { + return val; + } + } + if (entityConfig.tagFilter) { + const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath; + if (!entityConfig.tagFilter(tagName, jPathOrMatcher)) { + return val; + } + } + return this.entityDecoder.decode(val); +} +function saveTextToParentTag(textData, parentNode, matcher, isLeafNode) { + if (textData) { + if (isLeafNode === undefined) + isLeafNode = parentNode.child.length === 0; + textData = this.parseTextData(textData, parentNode.tagname, matcher, false, parentNode[":@"] ? Object.keys(parentNode[":@"]).length !== 0 : false, isLeafNode); + if (textData !== undefined && textData !== "") + parentNode.add(this.options.textNodeName, textData); + textData = ""; + } + return textData; +} +function isItStopNode() { + if (this.stopNodeExpressionsSet.size === 0) + return false; + return this.matcher.matchesAny(this.stopNodeExpressionsSet); +} +function tagExpWithClosingIndex(xmlData, i, closingChar = ">") { + let attrBoundary = 0; + const len = xmlData.length; + const closeCode0 = closingChar.charCodeAt(0); + const closeCode1 = closingChar.length > 1 ? closingChar.charCodeAt(1) : -1; + let result = ""; + let segmentStart = i; + for (let index = i;index < len; index++) { + const code = xmlData.charCodeAt(index); + if (attrBoundary) { + if (code === attrBoundary) + attrBoundary = 0; + } else if (code === 34 || code === 39) { + attrBoundary = code; + } else if (code === closeCode0) { + if (closeCode1 !== -1) { + if (xmlData.charCodeAt(index + 1) === closeCode1) { + result += xmlData.substring(segmentStart, index); + return { data: result, index }; + } + } else { + result += xmlData.substring(segmentStart, index); + return { data: result, index }; + } + } else if (code === 9 && !attrBoundary) { + result += xmlData.substring(segmentStart, index) + " "; + segmentStart = index + 1; + } + } +} +function findClosingIndex(xmlData, str, i, errMsg) { + const closingIndex = xmlData.indexOf(str, i); + if (closingIndex === -1) { + throw new Error(errMsg); + } else { + return closingIndex + str.length - 1; + } +} +function findClosingChar(xmlData, char, i, errMsg) { + const closingIndex = xmlData.indexOf(char, i); + if (closingIndex === -1) + throw new Error(errMsg); + return closingIndex; +} +function readTagExp(xmlData, i, removeNSPrefix, closingChar = ">") { + const result = tagExpWithClosingIndex(xmlData, i + 1, closingChar); + if (!result) + return; + let tagExp = result.data; + const closeIndex = result.index; + const separatorIndex = tagExp.search(/\s/); + let tagName = tagExp; + let attrExpPresent = true; + if (separatorIndex !== -1) { + tagName = tagExp.substring(0, separatorIndex); + tagExp = tagExp.substring(separatorIndex + 1).trimStart(); + } + const rawTagName = tagName; + if (removeNSPrefix) { + const colonIndex = tagName.indexOf(":"); + if (colonIndex !== -1) { + tagName = tagName.substr(colonIndex + 1); + attrExpPresent = tagName !== result.data.substr(colonIndex + 1); + } + } + return { + tagName, + tagExp, + closeIndex, + attrExpPresent, + rawTagName + }; +} +function readStopNodeData(xmlData, tagName, i) { + const startIndex = i; + let openTagCount = 1; + const xmllen = xmlData.length; + for (;i < xmllen; i++) { + if (xmlData[i] === "<") { + const c1 = xmlData.charCodeAt(i + 1); + if (c1 === 47) { + const closeIndex = findClosingChar(xmlData, ">", i, `${tagName} is not closed`); + let closeTagName = xmlData.substring(i + 2, closeIndex).trim(); + if (closeTagName === tagName) { + openTagCount--; + if (openTagCount === 0) { + return { + tagContent: xmlData.substring(startIndex, i), + i: closeIndex + }; + } + } + i = closeIndex; + } else if (c1 === 63) { + const closeIndex = findClosingIndex(xmlData, "?>", i + 1, "StopNode is not closed."); + i = closeIndex; + } else if (c1 === 33 && xmlData.charCodeAt(i + 2) === 45 && xmlData.charCodeAt(i + 3) === 45) { + const closeIndex = findClosingIndex(xmlData, "-->", i + 3, "StopNode is not closed."); + i = closeIndex; + } else if (c1 === 33 && xmlData.charCodeAt(i + 2) === 91) { + const closeIndex = findClosingIndex(xmlData, "]]>", i, "StopNode is not closed.") - 2; + i = closeIndex; + } else { + const tagData = readTagExp(xmlData, i, ">"); + if (tagData) { + const openTagName = tagData && tagData.tagName; + if (openTagName === tagName && tagData.tagExp[tagData.tagExp.length - 1] !== "/") { + openTagCount++; + } + i = tagData.closeIndex; + } + } + } + } +} +function parseValue(val, shouldParse, options) { + if (shouldParse && typeof val === "string") { + const newval = val.trim(); + if (newval === "true") + return true; + else if (newval === "false") + return false; + else + return toNumber(val, options); + } else { + if (isExist(val)) { + return val; + } else { + return ""; + } + } +} +function transformTagName(fn, tagName, tagExp, options) { + if (fn) { + const newTagName = fn(tagName); + if (tagExp === tagName) { + tagExp = newTagName; + } + tagName = newTagName; + } + tagName = sanitizeName(tagName, options); + return { tagName, tagExp }; +} +function sanitizeName(name, options) { + if (criticalProperties.includes(name)) { + throw new Error(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`); + } else if (DANGEROUS_PROPERTY_NAMES.includes(name)) { + return options.onDangerousProperty(name); + } + return name; +} + +// node_modules/fast-xml-parser/src/xmlparser/node2json.js +var METADATA_SYMBOL2 = XmlNode.getMetaDataSymbol(); +function stripAttributePrefix(attrs, prefix) { + if (!attrs || typeof attrs !== "object") + return {}; + if (!prefix) + return attrs; + const rawAttrs = {}; + for (const key in attrs) { + if (key.startsWith(prefix)) { + const rawName = key.substring(prefix.length); + rawAttrs[rawName] = attrs[key]; + } else { + rawAttrs[key] = attrs[key]; + } + } + return rawAttrs; +} +function prettify(node, options, matcher, readonlyMatcher) { + return compress(node, options, matcher, readonlyMatcher); +} +function compress(arr, options, matcher, readonlyMatcher) { + let text; + const compressedObj = {}; + for (let i = 0;i < arr.length; i++) { + const tagObj = arr[i]; + const property = propName(tagObj); + if (property !== undefined && property !== options.textNodeName) { + const rawAttrs = stripAttributePrefix(tagObj[":@"] || {}, options.attributeNamePrefix); + matcher.push(property, rawAttrs); + } + if (property === options.textNodeName) { + if (text === undefined) + text = tagObj[property]; + else + text += "" + tagObj[property]; + } else if (property === undefined) { + continue; + } else if (tagObj[property]) { + let val = compress(tagObj[property], options, matcher, readonlyMatcher); + const isLeaf = isLeafTag(val, options); + if (tagObj[":@"]) { + assignAttributes(val, tagObj[":@"], readonlyMatcher, options); + } else if (Object.keys(val).length === 1 && val[options.textNodeName] !== undefined && !options.alwaysCreateTextNode) { + val = val[options.textNodeName]; + } else if (Object.keys(val).length === 0) { + if (options.alwaysCreateTextNode) + val[options.textNodeName] = ""; + else + val = ""; + } + if (tagObj[METADATA_SYMBOL2] !== undefined && typeof val === "object" && val !== null) { + val[METADATA_SYMBOL2] = tagObj[METADATA_SYMBOL2]; + } + if (compressedObj[property] !== undefined && Object.prototype.hasOwnProperty.call(compressedObj, property)) { + if (!Array.isArray(compressedObj[property])) { + compressedObj[property] = [compressedObj[property]]; + } + compressedObj[property].push(val); + } else { + const jPathOrMatcher = options.jPath ? readonlyMatcher.toString() : readonlyMatcher; + if (options.isArray(property, jPathOrMatcher, isLeaf)) { + compressedObj[property] = [val]; + } else { + compressedObj[property] = val; + } + } + if (property !== undefined && property !== options.textNodeName) { + matcher.pop(); + } + } + } + if (typeof text === "string") { + if (text.length > 0) + compressedObj[options.textNodeName] = text; + } else if (text !== undefined) + compressedObj[options.textNodeName] = text; + return compressedObj; +} +function propName(obj) { + const keys = Object.keys(obj); + for (let i = 0;i < keys.length; i++) { + const key = keys[i]; + if (key !== ":@") + return key; + } +} +function assignAttributes(obj, attrMap, readonlyMatcher, options) { + if (attrMap) { + const keys = Object.keys(attrMap); + const len = keys.length; + for (let i = 0;i < len; i++) { + const atrrName = keys[i]; + const rawAttrName = atrrName.startsWith(options.attributeNamePrefix) ? atrrName.substring(options.attributeNamePrefix.length) : atrrName; + const jPathOrMatcher = options.jPath ? readonlyMatcher.toString() + "." + rawAttrName : readonlyMatcher; + if (options.isArray(atrrName, jPathOrMatcher, true, true)) { + obj[atrrName] = [attrMap[atrrName]]; + } else { + obj[atrrName] = attrMap[atrrName]; + } + } + } +} +function isLeafTag(obj, options) { + const { textNodeName } = options; + const propCount = Object.keys(obj).length; + if (propCount === 0) { + return true; + } + if (propCount === 1 && (obj[textNodeName] || typeof obj[textNodeName] === "boolean" || obj[textNodeName] === 0)) { + return true; + } + return false; +} + +// node_modules/fast-xml-parser/src/xmlparser/XMLParser.js +class XMLParser { + constructor(options) { + this.externalEntities = {}; + this.options = buildOptions(options); + } + parse(xmlData, validationOption) { + if (typeof xmlData !== "string" && xmlData.toString) { + xmlData = xmlData.toString(); + } else if (typeof xmlData !== "string") { + throw new Error("XML data is accepted in String or Bytes[] form."); + } + if (validationOption) { + if (validationOption === true) + validationOption = {}; + const result = validate(xmlData, validationOption); + if (result !== true) { + throw Error(`${result.err.msg}:${result.err.line}:${result.err.col}`); + } + } + const orderedObjParser = new OrderedObjParser(this.options, this.externalEntities); + const orderedResult = orderedObjParser.parseXml(xmlData); + if (this.options.preserveOrder || orderedResult === undefined) + return orderedResult; + else + return prettify(orderedResult, this.options, orderedObjParser.matcher, orderedObjParser.readonlyMatcher); + } + addEntity(key, value) { + if (value.indexOf("&") !== -1) { + throw new Error("Entity value can't have '&'"); + } else if (key.indexOf("&") !== -1 || key.indexOf(";") !== -1) { + throw new Error("An entity must be set without '&' and ';'. Eg. use '#xD' for ' '"); + } else if (value === "&") { + throw new Error("An entity with value '&' is not permitted"); + } else { + this.externalEntities[key] = value; + } + } + static getMetaDataSymbol() { + return XmlNode.getMetaDataSymbol(); + } +} + +// scripts/lib/detect-maven.ts +var SPRING_BOOT_GROUP = "org.springframework.boot"; +var SPRING_BOOT_PARENT = "spring-boot-starter-parent"; +var SPRING_BOOT_BOM = "spring-boot-dependencies"; +var xmlParser = new XMLParser({ + ignoreAttributes: true, + trimValues: true, + parseTagValue: false, + isArray: (tagName) => tagName === "module" || tagName === "dependency" +}); +function parsePom(xml, file) { + let doc; + try { + doc = xmlParser.parse(xml); + } catch (err) { + return { + result: malformed(file, err), + hints: {} + }; + } + const project = doc?.project; + if (!project || typeof project !== "object") { + return { result: malformed(file, new Error("no root element")), hints: {} }; + } + const parent = readParent(project); + if (parent && parent.groupId === SPRING_BOOT_GROUP && parent.artifactId === SPRING_BOOT_PARENT) { + if (!parent.version) { + return { + result: unsupported(file, "spring-boot-starter-parent declared without ", "parent (no version)"), + hints: {} + }; + } + if (containsInterpolation(parent.version)) { + return { + result: requiresBuildTool(file, `spring-boot-starter-parent uses Maven property interpolation (${parent.version})`, "spring-boot-starter-parent in "), + hints: {} + }; + } + return { + result: detected2(parent.version, file, "spring-boot-starter-parent in "), + hints: {} + }; + } + const bomVersion = readSpringBootBomVersion(project); + if (bomVersion) { + if (containsInterpolation(bomVersion)) { + return { + result: requiresBuildTool(file, `spring-boot-dependencies uses Maven property interpolation (${bomVersion})`, "spring-boot-dependencies BOM in "), + hints: {} + }; + } + return { + result: detected2(bomVersion, file, "spring-boot-dependencies BOM in "), + hints: {} + }; + } + const hints = {}; + if (parent) + hints.parent = parent; + const modules = readModules(project); + if (modules.length > 0) + hints.modules = modules; + return { + result: notFound2(file), + hints + }; +} +function readParent(project) { + const raw = project.parent; + if (!raw || typeof raw !== "object") + return; + const p = raw; + const groupId = asString(p.groupId); + const artifactId = asString(p.artifactId); + const version = asString(p.version); + if (!groupId || !artifactId) + return; + const relativePath = asString(p.relativePath); + return { + groupId, + artifactId, + version: version ?? "", + ...relativePath ? { relativePath } : {} + }; +} +function readSpringBootBomVersion(project) { + const dm = project.dependencyManagement; + if (!dm || typeof dm !== "object") + return; + const deps = dm.dependencies; + if (!deps || typeof deps !== "object") + return; + const list = deps.dependency; + const arr = Array.isArray(list) ? list : list ? [list] : []; + for (const d of arr) { + if (!d || typeof d !== "object") + continue; + const dep = d; + if (asString(dep.groupId) === SPRING_BOOT_GROUP && asString(dep.artifactId) === SPRING_BOOT_BOM) { + const v = asString(dep.version); + if (v) + return v; + } + } + return; +} +function readModules(project) { + const m = project.modules; + if (!m || typeof m !== "object") + return []; + const list = m.module; + if (!list) + return []; + const arr = Array.isArray(list) ? list : [list]; + return arr.map(asString).filter((s) => typeof s === "string" && s.length > 0); +} +function asString(v) { + if (typeof v === "string") + return v.trim() || undefined; + if (typeof v === "number") + return String(v); + return; +} +function detected2(version, file, locator) { + return { + kind: "detected", + version, + source: { file, locator } + }; +} +function unsupported(file, reason, locator) { + const source = { file, locator }; + return { + kind: "unsupported", + reason, + suggestion: SUGGEST_BOOT_OVERRIDE, + source + }; +} +function notFound2(file) { + return { + kind: "not-found", + reason: `No Spring Boot version declared in ${file}`, + suggestion: `Run from a Spring project root, or pass --boot to override` + }; +} +function malformed(file, err) { + const reason = err instanceof Error ? `malformed XML: ${err.message}` : "malformed XML"; + return unsupported(file, reason, "pom.xml parse error"); +} +var INTERPOLATION_RE = /\$\{[^}]+\}/; +function containsInterpolation(value) { + return INTERPOLATION_RE.test(value); +} +function requiresBuildTool(file, detail, locator) { + return { + kind: "unsupported", + reason: `${REQUIRES_BUILD_TOOL}: ${detail}`, + suggestion: `This pattern needs Maven/Gradle evaluation (build-tool fallback per ADR-0002). ${SUGGEST_BOOT_OVERRIDE}`, + source: { file, locator } + }; +} + +// scripts/lib/detect-published-catalog.ts +import { join } from "node:path"; +var FROM_CALL_RE = /from\s*\(\s*['"]([^:'"]+):([^:'"]+):([^'"]+)['"]\s*\)/; +var CREATE_CALL_RE = /create\s*\(\s*['"]([^'"]+)['"]\s*\)/g; +var VERSION_CATALOGS_KEYWORD = "versionCatalogs"; +var SPACE = 32; +var TAB = 9; +var NEWLINE = 10; +var CR = 13; +function isAsciiWhitespace(charCode) { + return charCode === SPACE || charCode === TAB || charCode === NEWLINE || charCode === CR; +} +function parsePublishedCatalogs(source) { + const block = extractVersionCatalogsBlock(source); + if (!block) + return []; + const out = []; + CREATE_CALL_RE.lastIndex = 0; + let createMatch = CREATE_CALL_RE.exec(block); + while (createMatch !== null) { + const alias = createMatch[1]; + if (alias) { + const blockEnd = findCreateBlockEnd(block, createMatch.index + createMatch[0].length); + const inner = block.slice(createMatch.index, blockEnd); + const fromMatch = FROM_CALL_RE.exec(inner); + if (fromMatch && fromMatch[1] && fromMatch[2] && fromMatch[3]) { + out.push({ + alias, + group: fromMatch[1], + artifact: fromMatch[2], + version: fromMatch[3] + }); + } + } + createMatch = CREATE_CALL_RE.exec(block); + } + return out; +} +function extractVersionCatalogsBlock(source) { + const idx = source.indexOf(VERSION_CATALOGS_KEYWORD); + if (idx === -1) + return; + const open = source.indexOf("{", idx + VERSION_CATALOGS_KEYWORD.length); + if (open === -1) + return; + let depth = 1; + let i = open + 1; + while (i < source.length && depth > 0) { + const ch = source.charCodeAt(i); + if (ch === 123) + depth++; + else if (ch === 125) + depth--; + i++; + } + if (depth !== 0) + return; + return source.slice(open + 1, i - 1); +} +function findCreateBlockEnd(block, fromIndex) { + let i = fromIndex; + while (i < block.length && isAsciiWhitespace(block.charCodeAt(i))) + i++; + if (i >= block.length || block.charCodeAt(i) !== 123) + return block.length; + let depth = 1; + i++; + while (i < block.length && depth > 0) { + const ch = block.charCodeAt(i); + if (ch === 123) + depth++; + else if (ch === 125) + depth--; + i++; + } + return i; +} +var GROUP_DOT_RE = /\./g; +function m2CatalogPath(group, artifact, version, m2Root) { + const groupPath = group.replace(GROUP_DOT_RE, "/"); + return join(m2Root, groupPath, artifact, version, `${artifact}-${version}.toml`); +} +function gradleCacheCatalogDir(group, artifact, version, gradleCachesRoot) { + return join(gradleCachesRoot, "modules-2", "files-2.1", group, artifact, version); +} +function pleaseaiCatalogCachePath(group, artifact, version, pleaseaiCacheRoot) { + return join(pleaseaiCacheRoot, "catalogs", `${group}-${artifact}-${version}.toml`); +} + +// scripts/lib/maven-cache.ts +import { join as join2 } from "node:path"; +var DOT_GLOBAL_RE2 = /\./g; +function mavenCachePath(groupId, artifactId, version, m2Root) { + const groupPath = groupId.replace(DOT_GLOBAL_RE2, "/"); + return join2(m2Root, groupPath, artifactId, version, `${artifactId}-${version}.pom`); +} + +// scripts/lib/overrides.ts +import { createHash } from "node:crypto"; +function projectKey(absoluteProjectDir) { + return createHash("sha256").update(absoluteProjectDir).digest("hex"); +} +function parseOverridesFile(content) { + if (content.trim() === "") + return {}; + let parsed; + try { + parsed = JSON.parse(content); + } catch { + return {}; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return {}; + return parsed; +} +function getOverride(store, key) { + return store[key]; +} +function setOverride(store, key, version, grantedAt) { + return { ...store, [key]: { version, grantedAt } }; +} +function clearOverride(store, key) { + if (!(key in store)) + return store; + const next = { ...store }; + delete next[key]; + return next; +} +function serializeOverrides(store) { + return `${JSON.stringify(store, null, 2)} +`; +} +var OVERRIDES_FILENAME = "overrides.json"; +var OVERRIDES_CACHE_SUBDIR = ".cache/pleaseai-spring"; + +// scripts/detect.ts +var POM = "pom.xml"; +var GRADLE_KTS = "build.gradle.kts"; +var GRADLE_GROOVY = "build.gradle"; +var SETTINGS_KTS = "settings.gradle.kts"; +var SETTINGS_GROOVY = "settings.gradle"; +var VERSION_CATALOG = "gradle/libs.versions.toml"; +var GRADLE_PROPERTIES = "gradle.properties"; +var MAX_PARENT_HOPS = 5; +var M2_ENV_OVERRIDE = "PLEASEAI_SPRING_M2_ROOT"; +var CACHE_HOME_ENV_OVERRIDE = "PLEASEAI_SPRING_CACHE_HOME"; +var WIN_SEP_RE = /\\/g; +function getM2Root() { + return process.env[M2_ENV_OVERRIDE] ?? join3(homedir(), ".m2", "repository"); +} +function getCacheHome() { + return process.env[CACHE_HOME_ENV_OVERRIDE] ?? homedir(); +} +function getOverridesPath() { + return join3(getCacheHome(), OVERRIDES_CACHE_SUBDIR, OVERRIDES_FILENAME); +} +function getGradleCachesRoot() { + return process.env.PLEASEAI_SPRING_GRADLE_CACHES ?? join3(homedir(), ".gradle", "caches"); +} +function getPleaseaiCatalogRoot() { + return join3(getCacheHome(), ".cache", "pleaseai-spring"); +} +async function detect(projectDir) { + if (!isExistingDirectory(projectDir)) { + return notFound3(projectDir); + } + const overrideHit = readOverrideForProject(projectDir); + if (overrideHit) + return overrideHit; + const pomPath = join3(projectDir, POM); + if (existsSync(pomPath)) { + return resolveMaven(projectDir, pomPath); + } + for (const fname of [GRADLE_KTS, GRADLE_GROOVY]) { + const p = join3(projectDir, fname); + if (existsSync(p)) { + return resolveGradle(projectDir, p, fname); + } + } + return notFound3(projectDir); +} +function readOverrideForProject(projectDir) { + const path = getOverridesPath(); + if (!existsSync(path)) + return; + const store = parseOverridesFile(readFileSync(path, "utf8")); + const entry = getOverride(store, projectKey(resolve(projectDir))); + if (!entry) + return; + return { + kind: "detected", + version: entry.version, + source: { + file: path, + locator: `--boot override (granted ${entry.grantedAt})` + } + }; +} +function grantBootOverride(projectDir, version) { + const path = getOverridesPath(); + mkdirSync(dirname(path), { recursive: true }); + const previous = existsSync(path) ? parseOverridesFile(readFileSync(path, "utf8")) : {}; + const next = setOverride(previous, projectKey(resolve(projectDir)), version, new Date().toISOString()); + writeFileSync(path, serializeOverrides(next)); +} +function clearBootOverride(projectDir) { + const path = getOverridesPath(); + if (!existsSync(path)) + return; + const previous = parseOverridesFile(readFileSync(path, "utf8")); + const next = clearOverride(previous, projectKey(resolve(projectDir))); + writeFileSync(path, serializeOverrides(next)); +} +function resolveGradle(projectDir, rootBuildPath, rootRel) { + const src = readFileSync(rootBuildPath, "utf8"); + const { result, hints } = parseGradle(src, rootRel); + if (result.kind === "detected" || result.kind === "unsupported") { + return result; + } + if (hints.catalogReference) { + const catalogAbs = join3(projectDir, VERSION_CATALOG); + if (existsSync(catalogAbs)) { + const tomlSrc = readFileSync(catalogAbs, "utf8"); + const v = resolveCatalogVersion(tomlSrc, hints.catalogReference.aliasPath); + if (v) { + return { + kind: "detected", + version: v, + source: { + file: VERSION_CATALOG, + locator: `version catalog alias '${hints.catalogReference.aliasPath}' in [versions]` + } + }; + } + } + const publishedHit = resolvePublishedCatalog(projectDir, hints.catalogReference.aliasPath); + if (publishedHit) + return publishedHit; + } + if (hints.propertyReference) { + const propsAbs = join3(projectDir, GRADLE_PROPERTIES); + if (existsSync(propsAbs)) { + const propsSrc = readFileSync(propsAbs, "utf8"); + const v = resolveProperty(propsSrc, hints.propertyReference.name); + if (v) { + return { + kind: "detected", + version: v, + source: { + file: GRADLE_PROPERTIES, + locator: `${hints.propertyReference.name} (gradle.properties)` + } + }; + } + } + } + const pmResult = resolvePluginManagement(projectDir); + if (pmResult) + return pmResult; + const subprojectsResult = walkGradleSubprojects(projectDir, result); + if (subprojectsResult.kind === "detected") + return subprojectsResult; + const buildToolEscalation = detectGradleRequiresBuildTool(projectDir); + if (buildToolEscalation) + return buildToolEscalation; + return subprojectsResult; +} +function detectGradleRequiresBuildTool(projectDir) { + const triggers = []; + if (existsSync(join3(projectDir, "buildSrc"))) + triggers.push("buildSrc/ directory"); + for (const initName of ["init.gradle", "init.gradle.kts"]) { + if (existsSync(join3(projectDir, initName))) + triggers.push(initName); + } + const settingsRel = findSettingsFile(projectDir); + if (settingsRel) { + const src = readFileSync(join3(projectDir, settingsRel), "utf8"); + if (settingsAppliesPlugin(src)) + triggers.push(`settings plugin in ${settingsRel}`); + } + if (triggers.length === 0) + return; + return requiresBuildToolResult(triggers, settingsRel ?? ""); +} +var SETTINGS_APPLY_RE = /^\s*apply\s*[<(]/m; +function settingsAppliesPlugin(source) { + return SETTINGS_APPLY_RE.test(stripPluginManagementBlock(source)); +} +function requiresBuildToolResult(triggers, file) { + return { + kind: "unsupported", + reason: `${REQUIRES_BUILD_TOOL}: Gradle pattern requires evaluation — ${triggers.join(", ")}`, + suggestion: `These patterns need Gradle evaluation (build-tool fallback per ADR-0002). ${SUGGEST_BOOT_OVERRIDE}`, + ...file ? { source: { file, locator: "requires-build-tool escalation" } } : {} + }; +} +function resolvePublishedCatalog(projectDir, aliasPath) { + const settingsRel = findSettingsFile(projectDir); + if (!settingsRel) + return; + const settingsSrc = readFileSync(join3(projectDir, settingsRel), "utf8"); + const catalogs = parsePublishedCatalogs(settingsSrc); + if (catalogs.length === 0) + return; + for (const cat of catalogs) { + const tomlPath = locatePublishedCatalogToml(cat); + if (!tomlPath) + continue; + const tomlSrc = readFileSync(tomlPath, "utf8"); + const v = resolveCatalogVersion(tomlSrc, aliasPath); + if (v) { + return { + kind: "detected", + version: v, + source: { + file: tomlPath, + locator: `published catalog '${cat.alias}' (${cat.group}:${cat.artifact}:${cat.version}), alias '${aliasPath}'` + } + }; + } + } + return; +} +function locatePublishedCatalogToml(cat) { + const m2 = m2CatalogPath(cat.group, cat.artifact, cat.version, getM2Root()); + if (existsSync(m2)) + return m2; + const gradleDir = gradleCacheCatalogDir(cat.group, cat.artifact, cat.version, getGradleCachesRoot()); + if (existsSync(gradleDir)) { + const found = findInHashedDir(gradleDir, `${cat.artifact}-${cat.version}.toml`); + if (found) + return found; + } + const owned = pleaseaiCatalogCachePath(cat.group, cat.artifact, cat.version, getPleaseaiCatalogRoot()); + if (existsSync(owned)) + return owned; + return; +} +function findInHashedDir(parent, filename) { + let entries; + try { + entries = readdirSync(parent); + } catch { + return; + } + for (const entry of entries) { + const candidate = join3(parent, entry, filename); + if (existsSync(candidate)) + return candidate; + } + return; +} +function resolvePluginManagement(projectDir) { + const settingsRel = findSettingsFile(projectDir); + if (!settingsRel) + return; + const src = readFileSync(join3(projectDir, settingsRel), "utf8"); + const v = parseSettingsPluginManagement(src); + if (!v) + return; + return { + kind: "detected", + version: v, + source: { + file: settingsRel, + locator: "pluginManagement plugins block" + } + }; +} +function walkGradleSubprojects(projectDir, fallback) { + const settingsRel = findSettingsFile(projectDir); + if (!settingsRel) + return fallback; + const settingsAbs = join3(projectDir, settingsRel); + const includes = parseSettingsIncludes(readFileSync(settingsAbs, "utf8")); + for (const inc of includes) { + for (const buildName of [GRADLE_KTS, GRADLE_GROOVY]) { + const childRel = `${inc.subdir}/${buildName}`; + const childAbs = join3(projectDir, inc.subdir, buildName); + if (!existsSync(childAbs)) + continue; + const src = readFileSync(childAbs, "utf8"); + const { result } = parseGradle(src, childRel); + if (result.kind === "detected") + return result; + break; + } + } + return fallback; +} +function findSettingsFile(projectDir) { + for (const name of [SETTINGS_KTS, SETTINGS_GROOVY]) { + if (existsSync(join3(projectDir, name))) + return name; + } + return; +} +function isExistingDirectory(p) { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} +function resolveMaven(projectDir, initialPath) { + let currentAbs = initialPath; + let currentRel = POM; + let modulesAtRoot; + for (let hop = 0;hop < MAX_PARENT_HOPS; hop++) { + const xml = readFileSync(currentAbs, "utf8"); + const { result, hints } = parsePom(xml, currentRel); + if (result.kind === "detected" || result.kind === "unsupported") { + return result; + } + if (hop === 0 && hints.modules && hints.modules.length > 0) { + modulesAtRoot = hints.modules; + } + if (!hints.parent) { + return walkModulesIfAny(projectDir, modulesAtRoot, result); + } + let nextAbs; + if (hints.parent.relativePath) { + const candidate = resolve(dirname(currentAbs), hints.parent.relativePath); + if (existsSync(candidate)) + nextAbs = candidate; + } + if (!nextAbs) { + const m2Path = mavenCachePath(hints.parent.groupId, hints.parent.artifactId, hints.parent.version, getM2Root()); + if (existsSync(m2Path)) { + nextAbs = m2Path; + } else { + return externalParentNotCached(hints.parent, m2Path); + } + } + currentAbs = nextAbs; + currentRel = posixRelative(projectDir, currentAbs); + } + return parentTraversalExceeded(currentRel); +} +function walkModulesIfAny(projectDir, modules, fallback) { + if (!modules || modules.length === 0) + return fallback; + for (const m of modules) { + const childPom = join3(projectDir, m, POM); + if (!existsSync(childPom)) + continue; + const xml = readFileSync(childPom, "utf8"); + const childRel = posixRelative(projectDir, childPom); + const { result } = parsePom(xml, childRel); + if (result.kind === "detected") + return result; + } + return fallback; +} +function posixRelative(from, to) { + const fromAbs = resolve(from); + const toAbs = resolve(to); + const rel = relative(fromAbs, toAbs); + if (isAbsolute(rel) || rel.startsWith("..") || rel === "") + return toAbs.split(WIN_SEP_RE).join("/"); + return rel.split(WIN_SEP_RE).join("/"); +} +function parentTraversalExceeded(lastFile) { + return { + kind: "unsupported", + reason: `Maven parent traversal exceeded ${MAX_PARENT_HOPS} hops without finding a Spring Boot version`, + suggestion: SUGGEST_BOOT_OVERRIDE, + source: { file: lastFile, locator: `parent traversal stopped after ${MAX_PARENT_HOPS} hops` } + }; +} +function externalParentNotCached(parent, m2Path) { + const coords = `${parent.groupId}:${parent.artifactId}:${parent.version}`; + return { + kind: "unsupported", + reason: `external-parent-not-cached: parent ${coords} not present in ~/.m2 (looked up at ${m2Path})`, + suggestion: `Run the project's Maven build at least once (e.g., ./mvnw install -N) to populate the local cache, or pass --boot to override (${SUGGEST_BOOT_OVERRIDE})`, + source: { file: m2Path, locator: `external parent ${coords}` } + }; +} +function notFound3(projectDir) { + return { + kind: "not-found", + reason: `No supported build file at ${projectDir}`, + suggestion: `Run from a Spring project root, or pass --boot (${SUGGEST_BOOT_OVERRIDE})` + }; +} +function internalErrorResult(err) { + const reason = err instanceof Error ? `internal error: ${err.message}` : "internal error"; + return { + kind: "unsupported", + reason, + suggestion: SUGGEST_BOOT_OVERRIDE + }; +} +var USAGE = "usage: bun run scripts/detect.ts [--boot | --clear-override]"; +function parseArgs(argv) { + let projectDir; + let boot; + let clear = false; + for (let i = 0;i < argv.length; i++) { + const a = argv[i]; + if (a === "--boot") { + boot = argv[++i]; + if (!boot) + return { error: "--boot requires a version argument" }; + } else if (a === "--clear-override") { + clear = true; + } else if (a && !a.startsWith("--")) { + projectDir ??= a; + } else { + return { error: `unknown argument: ${a}` }; + } + } + if (!projectDir) + return { error: "missing " }; + return { projectDir, boot, clear }; +} +async function cli(argv) { + const parsed = parseArgs(argv); + if ("error" in parsed) { + process.stderr.write(`${parsed.error} +${USAGE} +`); + return 2; + } + if (parsed.clear) + clearBootOverride(parsed.projectDir); + if (parsed.boot) + grantBootOverride(parsed.projectDir, parsed.boot); + let result; + try { + result = await detect(parsed.projectDir); + } catch (err) { + process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)} +`); + process.stdout.write(`${JSON.stringify(internalErrorResult(err), null, 2)} +`); + return 2; + } + process.stdout.write(`${JSON.stringify(result, null, 2)} +`); + return result.kind === "detected" ? 0 : 1; +} +if (true) { + const code = await cli(process.argv.slice(2)); + process.exit(code); +} +export { + clearBootOverride, + detect, + grantBootOverride +}; diff --git a/skills/spring-docs/scripts/docs.mjs b/skills/spring-docs/scripts/docs.mjs new file mode 100644 index 0000000..947265a --- /dev/null +++ b/skills/spring-docs/scripts/docs.mjs @@ -0,0 +1,329 @@ +#!/usr/bin/env node +// Generated by `bun run build:skill` from scripts/docs.ts — do not edit. +// Regenerate and commit after changing anything under scripts/. +// @bun + +// scripts/docs.ts +import { Buffer } from "buffer"; +import { spawnSync } from "child_process"; +import { createHash, randomUUID } from "crypto"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { basename, dirname, join as join2 } from "path"; +import process from "process"; + +// scripts/lib/docs-cache.ts +import { join } from "node:path"; +var DOCS_REPO = "pleaseai/spring-docs"; +var CATALOG_URL = `https://raw.githubusercontent.com/${DOCS_REPO}/main/catalog.json`; +var DOCS_CACHE_SUBDIR = ".cache/pleaseai-spring/docs"; +var SUPPORTED_CATALOG_VERSION = "1"; +function lookupTag(catalog, project, version) { + if (catalog.version !== SUPPORTED_CATALOG_VERSION) + return { kind: "schema", found: catalog.version }; + const versions = catalog.projects[project]; + if (!versions) + return { kind: "unknown-project", project, known: Object.keys(catalog.projects).sort() }; + const entry = versions[version]; + if (!entry) + return { kind: "unknown-version", project, version, known: Object.keys(versions) }; + if (entry.released_at === null) + return { kind: "unpublished", project, version, tag: entry.tag }; + return { kind: "found", tag: entry.tag, releasedAt: entry.released_at }; +} +function archiveName(project, version) { + return `${project}-${version}.tar.gz`; +} +function archiveUrl(tag, project, version) { + return `https://github.com/${DOCS_REPO}/releases/download/${tag}/${archiveName(project, version)}`; +} +function checksumUrl(tag, project, version) { + return `${archiveUrl(tag, project, version)}.sha256`; +} +function isCatalog(value) { + if (!isObjectMap(value)) + return false; + if (typeof value.version !== "string") + return false; + const { projects } = value; + if (!isObjectMap(projects)) + return false; + return Object.values(projects).every(isVersionMap); +} +function isVersionMap(value) { + if (!isObjectMap(value)) + return false; + return Object.values(value).every((entry) => { + if (!isObjectMap(entry)) + return false; + if (typeof entry.tag !== "string") + return false; + return entry.released_at === null || typeof entry.released_at === "string"; + }); +} +function isObjectMap(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +var SAFE_SEGMENT_RE = /^[\w.+-]+$/; +function isSafeSegment(value) { + if (value === "." || value === "..") + return false; + return SAFE_SEGMENT_RE.test(value); +} +function docsCachePath(cacheHome, tag) { + return join(cacheHome, DOCS_CACHE_SUBDIR, tag); +} +var SHA256_LINE_RE = /^([0-9a-f]{64})\s+\*?(\S+)$/i; +function parseChecksum(contents, expectedName) { + const line = contents.trim().split(` +`)[0]?.trim(); + if (!line) + return; + const match = SHA256_LINE_RE.exec(line); + if (!match || !match[1] || !match[2]) + return; + if (match[2] !== expectedName) + return; + return match[1].toLowerCase(); +} + +// scripts/docs.ts +var CACHE_HOME_ENV_OVERRIDE = "PLEASEAI_SPRING_CACHE_HOME"; +function cacheHomeOf(explicit) { + return explicit ?? process.env[CACHE_HOME_ENV_OVERRIDE] ?? homedir(); +} +function pointerPath(cacheHome, project, version) { + return `${docsCachePath(cacheHome, `${project}-${version}`)}.tag`; +} +function readPointer(cacheHome, project, version) { + const path = pointerPath(cacheHome, project, version); + if (!existsSync(path)) + return; + const tag = readFileSync(path, "utf8").trim(); + if (!isSafeSegment(tag)) + return; + return tag; +} +function writePointer(cacheHome, project, version, tag) { + const path = pointerPath(cacheHome, project, version); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${tag} +`); +} +var INDEX_FILE = "_index.md"; +function ready(project, version, tag, path, cached) { + return { kind: "ready", project, version, tag, path, index: join2(path, INDEX_FILE), cached }; +} +function unavailable(project, version, reason, suggestion) { + return suggestion === undefined ? { kind: "unavailable", project, version, reason } : { kind: "unavailable", project, version, reason, suggestion }; +} +async function fetchText(fetchImpl, url) { + try { + const response = await fetchImpl(url); + if (!response.ok) + return { error: `GET ${url} \u2192 ${response.status}` }; + return await response.text(); + } catch (err) { + return { error: `GET ${url} failed: ${err instanceof Error ? err.message : String(err)}` }; + } +} +function isUsableTree(path) { + try { + return statSync(join2(path, INDEX_FILE)).isFile(); + } catch { + return false; + } +} +function publish(extracted, target) { + const displaced = existsSync(target) ? `${target}.replaced-${randomUUID()}` : undefined; + if (displaced !== undefined) + renameSync(target, displaced); + try { + renameSync(extracted, target); + } catch (err) { + if (displaced !== undefined) { + if (existsSync(target)) + discard(displaced); + else + renameSync(displaced, target); + } + throw err; + } + if (displaced !== undefined) + discard(displaced); +} +function discard(path) { + try { + rmSync(path, { recursive: true, force: true }); + } catch {} +} +var LEFTOVER_TTL_MS = 60 * 60 * 1000; +function sweepLeftovers(target) { + const parent = dirname(target); + const prefix = basename(target); + const cutoff = Date.now() - LEFTOVER_TTL_MS; + let entries; + try { + entries = readdirSync(parent); + } catch { + return; + } + for (const name of entries) { + if (!name.startsWith(`${prefix}.staging-`) && !name.startsWith(`${prefix}.replaced-`)) + continue; + const path = join2(parent, name); + try { + if (statSync(path).mtimeMs < cutoff) + rmSync(path, { recursive: true, force: true }); + } catch {} + } +} +function unpack(archive, project, version, target) { + mkdirSync(dirname(target), { recursive: true }); + const staging = mkdtempSync(`${target}.staging-`); + try { + const archivePath = join2(staging, archiveName(project, version)); + writeFileSync(archivePath, archive); + const result = spawnSync("tar", ["-xzf", archivePath, "-C", staging], { encoding: "utf8" }); + if (result.error) + throw new Error(`could not run tar: ${result.error.message}`); + if (result.status !== 0) + throw new Error(`tar exited ${result.status}: ${(result.stderr ?? "").trim()}`); + const extracted = join2(staging, `${project}-${version}`); + if (!existsSync(extracted)) + throw new Error(`archive does not contain ${project}-${version}/`); + if (!isUsableTree(extracted)) + throw new Error(`archive does not contain ${project}-${version}/${INDEX_FILE}`); + publish(extracted, target); + } finally { + rmSync(staging, { recursive: true, force: true }); + } +} +async function resolveDocs(options) { + const { project, version, refresh = false, noFetch = false } = options; + const fetchImpl = options.fetchImpl ?? ((url) => fetch(url)); + const cacheHome = cacheHomeOf(options.cacheHome); + if (!isSafeSegment(project) || !isSafeSegment(version)) { + return unavailable(project, version, "project and version may contain only letters, digits, dot, plus, hyphen and underscore"); + } + if (noFetch) { + const tag = readPointer(cacheHome, project, version); + const path = tag === undefined ? undefined : docsCachePath(cacheHome, tag); + if (tag === undefined || path === undefined || !isUsableTree(path)) { + return unavailable(project, version, `${project} ${version} is not in the local cache`, "drop --no-fetch to download it"); + } + return ready(project, version, tag, path, true); + } + const catalogText = await fetchText(fetchImpl, CATALOG_URL); + if (typeof catalogText !== "string") + return unavailable(project, version, catalogText.error, "check network access to raw.githubusercontent.com"); + let parsed; + try { + parsed = JSON.parse(catalogText); + } catch (err) { + return unavailable(project, version, `catalog.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); + } + if (!isCatalog(parsed)) + return unavailable(project, version, "catalog.json does not have the expected shape", "update the plugin"); + const catalog = parsed; + const lookup = lookupTag(catalog, project, version); + switch (lookup.kind) { + case "schema": + return unavailable(project, version, `catalog.json is schema version ${lookup.found}, this plugin understands 1`, "update the plugin"); + case "unknown-project": + return unavailable(project, version, `${DOCS_REPO} publishes no project "${project}"`, `known projects: ${lookup.known.join(", ") || "none"}`); + case "unknown-version": + return unavailable(project, version, `${DOCS_REPO} has not published ${project} ${version}`, `open an issue at https://github.com/${DOCS_REPO}/issues to have it built`); + case "unpublished": + return unavailable(project, version, `${DOCS_REPO} reserved ${lookup.tag} for ${project} ${version} but has published no archive under it`, `open an issue at https://github.com/${DOCS_REPO}/issues to have it built`); + } + const { tag } = lookup; + if (!isSafeSegment(tag)) { + return unavailable(project, version, `catalog.json maps ${project} ${version} to an unusable tag "${tag}"`, `report it at https://github.com/${DOCS_REPO}/issues`); + } + const target = docsCachePath(cacheHome, tag); + sweepLeftovers(target); + if (isUsableTree(target) && !refresh) { + writePointer(cacheHome, project, version, tag); + return ready(project, version, tag, target, true); + } + const checksumText = await fetchText(fetchImpl, checksumUrl(tag, project, version)); + if (typeof checksumText !== "string") + return unavailable(project, version, checksumText.error); + const expected = parseChecksum(checksumText, archiveName(project, version)); + if (expected === undefined) { + return unavailable(project, version, `the checksum published for ${tag} does not describe ${archiveName(project, version)}`); + } + let archive; + try { + const response = await fetchImpl(archiveUrl(tag, project, version)); + if (!response.ok) + return unavailable(project, version, `GET ${archiveUrl(tag, project, version)} \u2192 ${response.status}`); + archive = Buffer.from(await response.arrayBuffer()); + } catch (err) { + return unavailable(project, version, `downloading ${tag} failed: ${err instanceof Error ? err.message : String(err)}`); + } + const actual = createHash("sha256").update(archive).digest("hex"); + if (actual !== expected) { + return unavailable(project, version, `checksum mismatch for ${tag}: expected ${expected.slice(0, 12)}\u2026, got ${actual.slice(0, 12)}\u2026`, "nothing was written to the cache; retry, and report it if it persists"); + } + try { + unpack(archive, project, version, target); + } catch (err) { + return unavailable(project, version, `unpacking ${tag} failed: ${err instanceof Error ? err.message : String(err)}`); + } + writePointer(cacheHome, project, version, tag); + return ready(project, version, tag, target, false); +} +var USAGE = "usage: bun run scripts/docs.ts [--refresh] [--no-fetch]"; +function parseArgs(argv) { + const positional = []; + let refresh = false; + let noFetch = false; + for (const arg of argv) { + if (arg === "--refresh") + refresh = true; + else if (arg === "--no-fetch") + noFetch = true; + else if (arg.startsWith("--")) + return { error: `unknown argument: ${arg}` }; + else + positional.push(arg); + } + const [project, version, ...extra] = positional; + if (!project) + return { error: "missing " }; + if (!version) + return { error: "missing " }; + if (extra.length > 0) + return { error: `unexpected argument: ${extra[0]}` }; + return { project, version, refresh, noFetch }; +} +async function cli(argv) { + const parsed = parseArgs(argv); + if ("error" in parsed) { + process.stderr.write(`${parsed.error} +${USAGE} +`); + return 2; + } + let result; + try { + result = await resolveDocs(parsed); + } catch (err) { + process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)} +`); + return 2; + } + process.stdout.write(`${JSON.stringify(result, null, 2)} +`); + return result.kind === "ready" ? 0 : 1; +} +if (true) { + const code = await cli(process.argv.slice(2)); + process.exit(code); +} +export { + parseArgs, + resolveDocs +};