diff --git a/.bg-shell/manifest.json b/.bg-shell/manifest.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/.bg-shell/manifest.json @@ -0,0 +1 @@ +[] diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..7e766c3 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.i686-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..42cced6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Force LF line endings for all text files on every platform. +# Without this, Windows checkouts apply core.autocrlf=true which +# breaks snapshot byte-for-byte comparison and a markdown regex +# in __test__/diagnostic-codes-drift.spec.ts that hard-codes \n. +* text=auto eol=lf + +# Binary artifacts — never normalize. +*.node binary +*.png binary +*.jpg binary +*.ico binary diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..3e1053e --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + "group:allNonMajor", + ":preserveSemverRanges", + ":disablePeerDependencies" + ], + "labels": ["dependencies"], + "packageRules": [ + { + "matchPackageNames": ["@napi/cli", "napi", "napi-build", "napi-derive"], + "addLabels": ["napi-rs"], + "groupName": "napi-rs" + }, + { + "matchPackagePatterns": ["^eslint", "^@typescript-eslint"], + "groupName": "linter" + } + ], + "commitMessagePrefix": "chore: ", + "commitMessageAction": "bump up", + "commitMessageTopic": "{{depName}} version", + "ignoreDeps": [] +} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..9521b4e --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,317 @@ +name: CI +env: + DEBUG: napi:* + APP_NAME: openapi-ng + MACOSX_DEPLOYMENT_TARGET: '10.13' + CARGO_INCREMENTAL: '1' +permissions: + contents: write + id-token: write +'on': + push: + branches: + - main + tags-ignore: + - '**' + paths-ignore: + - '**/*.md' + - LICENSE + - '**/*.gitignore' + - .editorconfig + - docs/** + pull_request: null +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: setup pnpm + uses: pnpm/action-setup@v5 + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'pnpm' + - name: Install + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - name: Install dependencies + run: pnpm install + - name: ESLint + run: pnpm lint + - name: Cargo fmt + run: cargo fmt -- --check + - name: Clippy + run: cargo clippy + - name: Cargo test + run: cargo test --all-targets + test-rust-cross-os: + name: cargo test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ matrix.os }}-cargo-test + - name: Cargo test + run: cargo test --all-targets + build: + strategy: + fail-fast: false + matrix: + settings: + - host: macos-latest + target: x86_64-apple-darwin + build: pnpm build --target x86_64-apple-darwin + - host: windows-latest + build: pnpm build --target x86_64-pc-windows-msvc + target: x86_64-pc-windows-msvc + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + build: pnpm build --target x86_64-unknown-linux-gnu --use-napi-cross + - host: macos-latest + target: aarch64-apple-darwin + build: pnpm build --target aarch64-apple-darwin + - host: ubuntu-latest + target: aarch64-unknown-linux-gnu + build: pnpm build --target aarch64-unknown-linux-gnu --use-napi-cross + - host: windows-latest + target: aarch64-pc-windows-msvc + build: pnpm build --target aarch64-pc-windows-msvc + name: stable - ${{ matrix.settings.target }} - node@20 + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v6 + - name: setup pnpm + uses: pnpm/action-setup@v5 + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - name: Install + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: ${{ matrix.settings.target }} + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ~/.napi-rs + .cargo-cache + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }} + - uses: mlugg/setup-zig@v2 + if: ${{ contains(matrix.settings.target, 'musl') }} + with: + version: 0.15.2 + - name: Install cargo-zigbuild + uses: taiki-e/install-action@v2 + if: ${{ contains(matrix.settings.target, 'musl') }} + env: + GITHUB_TOKEN: ${{ github.token }} + with: + tool: cargo-zigbuild + - name: Setup toolchain + run: ${{ matrix.settings.setup }} + if: ${{ matrix.settings.setup }} + shell: bash + - name: Install dependencies + run: pnpm install + - name: Setup node x86 + uses: actions/setup-node@v6 + if: matrix.settings.target == 'i686-pc-windows-msvc' + with: + node-version: 22 + cache: pnpm + architecture: x86 + - name: Build + run: ${{ matrix.settings.build }} + shell: bash + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: bindings-${{ matrix.settings.target }} + path: '*.node' + if-no-files-found: error + test-macOS-windows-binding: + name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + settings: + - host: windows-latest + target: x86_64-pc-windows-msvc + architecture: x64 + - host: macos-latest + target: x86_64-apple-darwin + architecture: x64 + - host: macos-latest + target: aarch64-apple-darwin + architecture: arm64 + node: + - '20' + - '22' + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v6 + - name: setup pnpm + uses: pnpm/action-setup@v5 + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: pnpm + architecture: ${{ matrix.settings.architecture }} + - name: Install dependencies + run: pnpm install + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: bindings-${{ matrix.settings.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + run: pnpm test + test-linux-binding: + name: Test ${{ matrix.target }} - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + node: + - '20' + - '22' + runs-on: ${{ contains(matrix.target, 'aarch64') && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v6 + - name: setup pnpm + uses: pnpm/action-setup@v5 + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - name: Output docker params + id: docker + run: | + node -e " + if ('${{ matrix.target }}'.startsWith('aarch64')) { + console.log('PLATFORM=linux/arm64') + } else if ('${{ matrix.target }}'.startsWith('armv7')) { + console.log('PLATFORM=linux/arm/v7') + } else { + console.log('PLATFORM=linux/amd64') + } + " >> $GITHUB_OUTPUT + node -e " + if ('${{ matrix.target }}'.endsWith('-musl')) { + console.log('IMAGE=node:${{ matrix.node }}-alpine') + } else { + console.log('IMAGE=node:${{ matrix.node }}-slim') + } + " >> $GITHUB_OUTPUT + echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + # use --force to download the all platform/arch dependencies + - name: Install dependencies + run: pnpm install --force + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: bindings-${{ matrix.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + if: ${{ contains(matrix.target, 'armv7') }} + with: + platforms: all + - run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + if: ${{ contains(matrix.target, 'armv7') }} + - name: Test bindings + uses: tj-actions/docker-run@v2 + with: + image: ${{ steps.docker.outputs.IMAGE }} + name: test-binding + options: -v ${{ steps.docker.outputs.PNPM_STORE_PATH }}:${{ steps.docker.outputs.PNPM_STORE_PATH }} -v ${{ github.workspace }}:${{ github.workspace }} -w ${{ github.workspace }} --platform ${{ steps.docker.outputs.PLATFORM }} + args: npm run test + publish: + name: Publish + runs-on: ubuntu-latest + needs: + - lint + - test-rust-cross-os + - test-macOS-windows-binding + - test-linux-binding + steps: + - uses: actions/checkout@v6 + - name: setup pnpm + uses: pnpm/action-setup@v5 + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - name: Install dependencies + run: pnpm install + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + - name: create npm dirs + run: pnpm napi create-npm-dirs + - name: Move artifacts + run: pnpm artifacts + - name: List packages + run: ls -R ./npm + shell: bash + - name: Publish + run: | + npm config set provenance true + if git log -1 --pretty=%B | grep "^v\?[0-9]\+\.[0-9]\+\.[0-9]\+$"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --access public + elif git log -1 --pretty=%B | grep "^v\?[0-9]\+\.[0-9]\+\.[0-9]\+"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --tag next --access public + else + echo "Not a release, skipping publish" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..a412456 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,59 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'website/**' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'website/**' + - '.github/workflows/docs.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Build and deploy + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v5 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: website/pnpm-lock.yaml + - name: Install dependencies + working-directory: website + run: pnpm install --frozen-lockfile + - name: Build site + working-directory: website + run: pnpm build + - name: Deploy to production + if: github.event_name == 'push' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + workingDirectory: website + command: deploy + - name: Upload preview version + if: github.event_name == 'pull_request' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + workingDirectory: website + command: versions upload --tag pr-${{ github.event.pull_request.number }} --message "PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c896fcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,236 @@ +### Created by https://www.gitignore.io + +### IntelliJ ### +.idea + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +#Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### Created by https://www.gitignore.io +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +*.node + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache +.stylelintcache + +# SvelteKit build / generate output +.svelte-kit + +### Created by https://www.gitignore.io +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud +.planning/ + +# ── GSD baseline (auto-generated) ── +.gsd/activity/ +.gsd/forensics/ +.gsd/runtime/ +.gsd/worktrees/ +.gsd/parallel/ +.gsd/auto.lock +.gsd/metrics.json +.gsd/completed-units.json +.gsd/STATE.md +.gsd/gsd.db +.gsd/DISCUSSION-MANIFEST.json +.gsd/milestones/**/*-CONTINUE.md +.gsd/milestones/**/continue.md +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ +*.code-workspace +.env.* +!.env.example +.next/ +dist/ +build/ +__pycache__/ +*.pyc +.venv/ +venv/ +vendor/ +coverage/ +tmp/ + +*.bak + diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..e236480 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,21 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 90, + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "arrowParens": "avoid", + "sortPackageJson": true, + "ignorePatterns": [ + "target", + ".yarn", + "/*.js", + "/*.mjs", + "/*.cjs", + "/index.js", + "index.d.ts", + "pnpm-lock.yaml", + "/test/fixtures/malformed.yaml", + "__test__/snapshots/generate-native" + ] +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ad66444 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,610 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "napi" +version = "3.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb7848c221fb7bb789e02f01875287ebb1e078b92a6566a34de01ef8806e7c2b" +dependencies = [ + "bitflags", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "3.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60867ff9a6f76e82350e0c3420cb0736f5866091b61d7d8a024baa54b0ec17dd" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0864cf6a82e2cfb69067374b64c9253d7e910e5b34db833ed7495dda56ccb18" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "openapi_ng" +version = "0.0.0" +dependencies = [ + "indexmap", + "napi", + "napi-build", + "napi-derive", + "proptest", + "regex", + "serde", + "serde_json", + "serde_yml", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ddcb276 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[package] +edition = "2024" +name = "openapi_ng" +version = "0.0.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +indexmap = { version = "2", features = ["serde"] } +napi = "3.0.0" +napi-derive = "3.0.0" +regex = "1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yml = "0.0.12" + +[build-dependencies] +napi-build = "2" + +[dev-dependencies] +proptest = { version = "1.7", default-features = false, features = ["std"] } + +[lints.clippy] +redundant_clone = "warn" +missing_const_for_fn = "warn" +manual_let_else = "warn" +map_unwrap_or = "warn" +option_if_let_else = "warn" +unnested_or_patterns = "warn" +cast_possible_truncation = "warn" +single_match_else = "warn" +redundant_closure_for_method_calls = "warn" + +[profile.release] +# Build-time codegen tool: prioritise binary size (cold path, distributed +# as napi prebuilds). `opt-level = "s"` keeps the inliner conservative +# enough that overall runtime stays within noise of "z" / "3"; combined +# with LTO + 1 codegen unit + strip, this trims roughly 10% off the +# .node bundle vs the prior opt-level = "3" default. +opt-level = "s" +lto = true +codegen-units = 1 +strip = "symbols" diff --git a/LICENSE b/LICENSE index b663eca..1c05aa8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 AVSystem +Copyright (c) 2026 pkurcx Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5c51b2c..5b706c5 100644 --- a/README.md +++ b/README.md @@ -1 +1,75 @@ -# openapi-ng \ No newline at end of file +# openapi-ng + +Generate TypeScript models and Angular services from OpenAPI 3.x specs — fast, deterministic, Rust-powered. + +**[Documentation →](https://docs.openapi-ng.dev)** · [Getting started](https://docs.openapi-ng.dev/getting-started/) · [Angular guide](https://docs.openapi-ng.dev/guides/angular/) · [Node API](https://docs.openapi-ng.dev/reference/node-api/) · [Diagnostics](https://docs.openapi-ng.dev/reference/diagnostics/) + +## Why openapi-ng + +- **Rust-powered codegen.** The engine is a native binary loaded via [NAPI-RS](https://napi.rs). The same input always produces identical output. +- **Angular-first output.** Each operation ships with three flavors — `.observable()`, `.resource()`, `.request()` — matching Angular's current HTTP primitives. +- **Strict OpenAPI subset.** A focused 3.x slice with clear diagnostics. No silent misgeneration; see [Assumptions & limitations](https://docs.openapi-ng.dev/reference/limitations/) for the accepted shape. +- **Configurable naming.** Tune method names and service grouping with template + regex rules, via YAML, JSON, or TypeScript config. + +## Install + +```bash +pnpm add -D @avsystem/openapi-ng +``` + +Requires Node.js >= 18. Pre-built binaries for macOS, Linux, and Windows (x64 / ARM64). See [Runtime & platforms](https://docs.openapi-ng.dev/reference/runtime/). + +## Quickstart + +```bash +openapi-ng generate --input petstore.openapi.yaml --output ./generated +``` + +``` +✓ Generated 4 files from Petstore (3.0.3) + 1 path · 1 operation · 1 schema + + model.generated.ts + rest.model.ts + rest.util.ts + rest/pet.rest.generated.ts +``` + +Wire a generated service into a component: + +```ts +import { Component, inject } from '@angular/core'; +import { PetRest } from './generated/rest/pet.rest.generated'; + +@Component({ + /* ... */ +}) +export class PetList { + readonly #pets = inject(PetRest); + + // Signal-based, reactive, with a default value while loading. + readonly list = this.#pets.listPets.resource({ defaultValue: [] }); +} +``` + +Full walkthrough on [docs.openapi-ng.dev/getting-started](https://docs.openapi-ng.dev/getting-started/). + +## Development + +```bash +pnpm install +pnpm build # release build (Rust + NAPI) +pnpm build:debug # debug build (faster compile) +pnpm test # Node integration tests (AVA) +cargo test # Rust unit tests +pnpm lint # oxlint +pnpm format # oxfmt + rustfmt + taplo +``` + +Rust changes require a rebuild (`pnpm build` or `pnpm build:debug`) before tests reflect them. + +Bug reports and PRs welcome at [github.com/AVSystem/openapi-ng](https://github.com/AVSystem/openapi-ng). + +## License + +[MIT](./LICENSE) © 2026 pkurcx diff --git a/__test__/.gitignore b/__test__/.gitignore new file mode 100644 index 0000000..e82b313 --- /dev/null +++ b/__test__/.gitignore @@ -0,0 +1,3 @@ +**/generated +**/generated-formats +**/__snapshot_compile__ diff --git a/__test__/angular-consumer/package.json b/__test__/angular-consumer/package.json new file mode 100644 index 0000000..c2ff7a4 --- /dev/null +++ b/__test__/angular-consumer/package.json @@ -0,0 +1,10 @@ +{ + "name": "@avsystem/openapi-ng-angular-consumer-proof", + "private": true, + "type": "module", + "dependencies": { + "@angular/common": "21.2.5", + "@angular/core": "21.2.5", + "rxjs": "7.8.2" + } +} diff --git a/__test__/angular-consumer/src/base-path-proof.ts b/__test__/angular-consumer/src/base-path-proof.ts new file mode 100644 index 0000000..f9ba238 --- /dev/null +++ b/__test__/angular-consumer/src/base-path-proof.ts @@ -0,0 +1,16 @@ +import type { EnvironmentProviders, InjectionToken } from '@angular/core'; +import { OPENAPI_NG_BASE_PATH, provideOpenapiNg } from '../generated/rest.util'; + +declare function expectType(value: T): void; + +expectType>(OPENAPI_NG_BASE_PATH); + +expectType( + provideOpenapiNg({ basePath: 'https://api.example.com' }), +); + +// @ts-expect-error — basePath is required +provideOpenapiNg({}); + +// @ts-expect-error — basePath must be a string +provideOpenapiNg({ basePath: 123 }); diff --git a/__test__/angular-consumer/src/consumer-proof.ts b/__test__/angular-consumer/src/consumer-proof.ts new file mode 100644 index 0000000..6ee5403 --- /dev/null +++ b/__test__/angular-consumer/src/consumer-proof.ts @@ -0,0 +1,65 @@ +import type { HttpClient } from '@angular/common/http'; + +import type { + AdoptionDecision, + AdoptionRequest, + ContactEmail, + ContactPhone, + PetUnion, + PetUnionList, +} from '../generated/model.generated'; + +declare const http: HttpClient; + +declare function expectType(value: T): void; + +const emailContact: ContactEmail = { email: 'owner@example.com' }; +const phoneContact: ContactPhone = { phone: '+48123456789' }; +const preferredPet: PetUnion = { + id: 'pet-123', + lives: 7, +}; + +const listUnionPets: PetUnionList = [preferredPet]; + +const nullableFreeRequest: AdoptionRequest = { + applicantName: 'Jordan Example', + contact: emailContact, + preferredPet, + notes: 'Has a fenced yard', + referralCode: 'SPRING-2026', +}; + +const applicantWithEmail: AdoptionRequest = { + applicantName: 'Taylor Example', + contact: emailContact, + preferredPet, + notes: 'Works from home', + referralCode: 'FRIEND', +}; + +const applicantWithPhone: AdoptionRequest = { + applicantName: 'Morgan Example', + contact: phoneContact, +}; + +const approvedDecision: AdoptionDecision = { + approved: true, + matchedPet: preferredPet, + reviewerNote: 'Bring carrier to pickup', +}; + +const pendingDecision: AdoptionDecision = { + approved: false, +}; + +expectType(applicantWithPhone.contact); +expectType(applicantWithEmail.preferredPet); +expectType(approvedDecision.reviewerNote); +expectType(approvedDecision.matchedPet); +expectType(listUnionPets); + +void http; +void nullableFreeRequest; +void approvedDecision; +void pendingDecision; diff --git a/__test__/angular-consumer/src/discriminator-proof.ts b/__test__/angular-consumer/src/discriminator-proof.ts new file mode 100644 index 0000000..a2f1aa1 --- /dev/null +++ b/__test__/angular-consumer/src/discriminator-proof.ts @@ -0,0 +1,38 @@ +import type { Cat, Dog, PetUnion } from '../generated/model.generated'; + +declare const pet: PetUnion; + +// These narrowing blocks only type-check if Cat.kind = 'cat' and Dog.kind = 'dog' +// (literal types). Without the discriminator narrowing the compiler cannot +// prove that pet.lives / pet.breed are accessible inside the if block. + +if (pet.kind === 'cat') { + const lives: number = pet.lives; + void lives; +} + +if (pet.kind === 'dog') { + const breed: string = pet.breed; + void breed; +} + +// Exhaustiveness check — both branches must be accounted for. +function assertNever(x: never): never { + throw new Error(`Unexpected value: ${x}`); +} + +function describePet(p: PetUnion): string { + switch (p.kind) { + case 'cat': + return `cat with ${p.lives} lives`; + case 'dog': + return `dog of breed ${p.breed}`; + default: + return assertNever(p); + } +} + +declare const cat: Cat; +declare const dog: Dog; +void describePet(cat); +void describePet(dog); diff --git a/__test__/angular-consumer/src/emit-target-proof.ts b/__test__/angular-consumer/src/emit-target-proof.ts new file mode 100644 index 0000000..e19cd60 --- /dev/null +++ b/__test__/angular-consumer/src/emit-target-proof.ts @@ -0,0 +1,22 @@ +// Type-level proof that `EmitTarget` survives single-file transpilation +// under `isolatedModules`. Compiles against the published `index.d.ts` +// (mapped to `@avsystem/openapi-ng` via tsconfig.emit-target.json's `paths`), so +// any regression that re-introduces `const enum EmitTarget` — which TS +// rejects when an isolatedModules consumer imports it across a module +// boundary — fails this gate before reaching downstream users. + +import { EmitTarget } from '@avsystem/openapi-ng'; +import type { EmitTarget as EmitTargetType } from '@avsystem/openapi-ng'; + +// Must be assignable from plain string literals (the runtime contract). +const a: EmitTargetType = 'models'; +const b: EmitTargetType = 'angular'; + +// And from the ambient frozen-const's named properties. +const c: EmitTargetType = EmitTarget.Models; +const d: EmitTargetType = EmitTarget.Angular; + +void a; +void b; +void c; +void d; diff --git a/__test__/angular-consumer/src/form-non-json-proof.ts b/__test__/angular-consumer/src/form-non-json-proof.ts new file mode 100644 index 0000000..629f0f6 --- /dev/null +++ b/__test__/angular-consumer/src/form-non-json-proof.ts @@ -0,0 +1,154 @@ +// Compile-time proofs for the request bodies and non-JSON responses +// surfaced by Phase 7. Lives next to service-proof.ts (the petstore-rich +// JSON proof) and compiles against a separate combined fixture +// (`consumer-forms-and-non-json.openapi.yaml`) generated into +// `__test__/angular-consumer/generated/` by the matching ava test. +// +// Each block asserts: +// 1. The request type accepts the right field shapes (Blob | File, +// number[], etc.). +// 2. `.observable(...)` and `.resource(...)` carry the right Response +// generic through to `Observable` / `HttpResourceRef<...>`. +// +// A regression that collapses any of these to `any` or rejects a valid +// call-site shape fails this file under `tsc --noEmit`. + +import type { HttpResourceRef } from '@angular/common/http'; +import type { Observable } from 'rxjs'; + +import type { CommonRequest } from '../generated/rest.model'; + +import type { BinaryRest } from '../generated/rest/binary.rest.generated'; +import type { ConfigRest } from '../generated/rest/config.rest.generated'; +import type { + DownloadInvoicePdfParams, + InvoiceRest, +} from '../generated/rest/invoice.rest.generated'; +import type { + PetRest, + UpdatePetAvatarParams, +} from '../generated/rest/pet.rest.generated'; +import type { + SearchRest, + SubmitFormParams, +} from '../generated/rest/search.rest.generated'; + +declare const petSvc: PetRest; +declare const searchSvc: SearchRest; +declare const invoiceSvc: InvoiceRest; +declare const configSvc: ConfigRest; +declare const binarySvc: BinaryRest; + +declare function expectType(value: T): void; + +/** + * multipart/form-data — mixed-field body. + * + * Asserts: + * - Binary field accepts `Blob | File`. + * - Repeated binary field accepts `(Blob | File)[]`. + * - Scalar array accepts `number[]` (the spec types it as integer). + * - Operation observable resolves the inline `{ id?: string }` shape. + */ +const multipartRequest: UpdatePetAvatarParams = { + petId: 'p-1', + status: 'available', + tagIds: [1, 2, 3], + avatar: new Blob(['avatar-bytes']), + galleries: [new Blob(['g1']), new File(['g2'], 'g2.png')], +}; + +const multipartObservable = petSvc.updatePetAvatar.observable(multipartRequest); +const multipartResource = petSvc.updatePetAvatar.resource(() => multipartRequest); + +expectType>(multipartObservable); +expectType>(multipartResource); + +/** + * application/x-www-form-urlencoded — scalar + scalar array. + * + * Asserts that the generated request interface keeps the same JS-side + * shape as a JSON body (no `Blob | File` leakage from the multipart + * branch) and that the response generic survives. + */ +const urlencodedRequest: SubmitFormParams = { + status: 'pending', + tagIds: [3, 4], +}; + +const urlencodedObservable = searchSvc.submitForm.observable(urlencodedRequest); +expectType>(urlencodedObservable); + +/** + * application/pdf response — classified as `Blob` by the default + * response-kind classifier. + * + * Asserts: + * - `.observable(...)` and `.resource(...)` carry `Blob` through. + * - `.request(...)` returns a CommonRequest (compile-only check). + * - `defaultValue` removes the `| undefined` from the resource ref. + * - `parse` projects the raw Blob to a caller-defined result type. + */ +const blobRequest = invoiceSvc.downloadInvoicePdf.request({ invoiceId: 'inv-42' }); +const blobObservable = invoiceSvc.downloadInvoicePdf.observable({ invoiceId: 'inv-42' }); +const blobResource = invoiceSvc.downloadInvoicePdf.resource( + (): DownloadInvoicePdfParams => ({ invoiceId: 'inv-42' }), +); +const blobResourceWithDefault = invoiceSvc.downloadInvoicePdf.resource( + (): DownloadInvoicePdfParams => ({ invoiceId: 'inv-42' }), + { defaultValue: new Blob() }, +); +const blobResourceWithParse = invoiceSvc.downloadInvoicePdf.resource( + (): DownloadInvoicePdfParams => ({ invoiceId: 'inv-42' }), + { parse: (raw: Blob): { size: number } => ({ size: raw.size }) }, +); + +expectType(blobRequest); +expectType>(blobObservable); +expectType>(blobResource); +expectType>(blobResourceWithDefault); +expectType>(blobResourceWithParse); + +/** + * text/plain response — classified as `string`. + * + * Same surface assertions as the blob block, instantiated for `string`. + */ +const textRequest = configSvc.getRawConfig.request(); +const textObservable = configSvc.getRawConfig.observable(); +const textResource = configSvc.getRawConfig.resource(); +const textResourceWithDefault = configSvc.getRawConfig.resource({ + defaultValue: '', +}); +const textResourceWithParse = configSvc.getRawConfig.resource({ + parse: (raw: string): number => raw.length, +}); + +expectType(textRequest); +expectType>(textObservable); +expectType>(textResource); +expectType>(textResourceWithDefault); +expectType>(textResourceWithParse); + +/** + * application/octet-stream — overridden to `arrayBuffer` via the + * generator's `responseTypeMapping` option. + * + * Same surface assertions as the blob block, instantiated for + * `ArrayBuffer`. + */ +const arrayBufferRequest = binarySvc.fetchBlob.request(); +const arrayBufferObservable = binarySvc.fetchBlob.observable(); +const arrayBufferResource = binarySvc.fetchBlob.resource(); +const arrayBufferResourceWithDefault = binarySvc.fetchBlob.resource({ + defaultValue: new ArrayBuffer(0), +}); +const arrayBufferResourceWithParse = binarySvc.fetchBlob.resource({ + parse: (raw: ArrayBuffer): number => raw.byteLength, +}); + +expectType(arrayBufferRequest); +expectType>(arrayBufferObservable); +expectType>(arrayBufferResource); +expectType>(arrayBufferResourceWithDefault); +expectType>(arrayBufferResourceWithParse); diff --git a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts new file mode 100644 index 0000000..dfb85ed --- /dev/null +++ b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts @@ -0,0 +1,22 @@ +// This file is INTENDED TO FAIL TypeScript compilation. +// It exists so the test suite catches type-soundness regressions on the +// multipart form-body surface: the binary field's request-interface +// type MUST stay `Blob | File`, never widen to `string`/`any`. If a +// future change accidentally collapses the binary field type, this +// assignment would succeed and tsc would exit 0 — causing the +// negative-compile test to fail and alerting us. +// +// Expected error: TS2322 — `'string-not-blob'` (a literal string) is not +// assignable to `Blob | File`. +import type { UpdatePetAvatarParams } from '../../generated/rest/pet.rest.generated'; + +// Construct an UpdatePetAvatarParams whose `avatar` field is a string, +// not a Blob/File. Every other field carries a valid value so the +// failure is unambiguously about the binary slot. +export const shouldFail: UpdatePetAvatarParams = { + petId: 'p-1', + status: 'available', + tagIds: [], + avatar: 'string-not-blob', + galleries: [], +}; diff --git a/__test__/angular-consumer/src/negative-proof/negative.ts b/__test__/angular-consumer/src/negative-proof/negative.ts new file mode 100644 index 0000000..f20350b --- /dev/null +++ b/__test__/angular-consumer/src/negative-proof/negative.ts @@ -0,0 +1,15 @@ +// This file is INTENDED TO FAIL TypeScript compilation. +// It exists so the test suite catches type-soundness regressions +// (e.g. if a future change accidentally collapses a tagged union to `any`). +// +// Expected error: TS2322 — the `kind` literal type 'dog' is not assignable to +// 'cat', so assigning an object with `kind: 'dog'` to a Cat-typed slot fails. +// If the union ever degrades to `any`, this assignment would succeed and tsc +// would exit 0 — causing the negative-compile test to fail and alerting us. +import type { Cat } from '../../generated/model.generated'; + +// Construct an object whose `kind` discriminant is 'dog', not 'cat'. +// This is structurally compatible with Cat except for the literal type on `kind`. +const dogKind = { kind: 'dog' as const, lives: 9 }; + +export const shouldFail: Cat = dogKind; diff --git a/__test__/angular-consumer/src/service-proof.ts b/__test__/angular-consumer/src/service-proof.ts new file mode 100644 index 0000000..15a1f95 --- /dev/null +++ b/__test__/angular-consumer/src/service-proof.ts @@ -0,0 +1,119 @@ +import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest.generated'; +import type { HttpEvent, HttpResourceRef, HttpResponse } from '@angular/common/http'; +import { Pet, PetList } from '../generated/model.generated.ts'; +import { Observable } from 'rxjs'; +import type { RequestFnVoid, ZeroArgRequestFnVoid } from '../generated/rest.util'; + +declare const service: PetRest; + +declare function expectType(value: T): void; + +/** + * listPets + * */ + +const listPetsRequest = service.listPets.request(); +const listPetsObservable = service.listPets.observable(); +const listPetsResource = service.listPets.resource(); + +const listPetsResourceDefaultValue = service.listPets.resource({ + defaultValue: [], +}); +const listPetsResourceParse = service.listPets.resource({ + parse: raw => raw.length, +}); +const listPetsResourceParseDefaultValue = service.listPets.resource({ + parse: raw => raw.length, + defaultValue: 42, +}); + +const listPetsObservableResponse = service.listPets.observable({ + observe: 'response', +}); +const listPetsObservableEvents = service.listPets.observable({ + observe: 'events', + reportProgress: true, +}); + +expectType(listPetsRequest.url); +expectType>(listPetsResource); +expectType>(listPetsObservable); +expectType>(listPetsResourceDefaultValue); +expectType>(listPetsResourceParse); +expectType>(listPetsResourceParseDefaultValue); +expectType>>(listPetsObservableResponse); +expectType>>(listPetsObservableEvents); + +/** + * updatePet + * */ + +const request: UpdatePetParams = { + petId: 'id', + body: { + status: 'available', + tagIds: [], + }, +}; +const defaultPet: Pet = { + id: 'id', + name: 'name', + status: 'available', + tags: [], +}; +const updatePetRequest = service.updatePet.request(request); +const updatePetObservable = service.updatePet.observable(request); +const updatePetResource = service.updatePet.resource(() => request); + +const updatePetResourceDefaultValue = service.updatePet.resource(() => request, { + defaultValue: defaultPet, +}); +const updatePetResourceParse = service.updatePet.resource(() => request, { + parse: raw => raw.tags.length, +}); +const updatePetResourceParseDefaultValue = service.updatePet.resource( + () => request, + { parse: raw => raw.tags.length, defaultValue: 42 }, +); + +const updatePetObservableResponse = service.updatePet.observable(request, { + observe: 'response', +}); +const updatePetObservableEvents = service.updatePet.observable(request, { + observe: 'events', + reportProgress: true, +}); + +expectType(updatePetRequest.url); +expectType>(updatePetObservable); +expectType>(updatePetResource); +expectType>(updatePetResourceDefaultValue); +expectType>(updatePetResourceParse); +expectType>(updatePetResourceParseDefaultValue); +expectType>>(updatePetObservableResponse); +expectType>>(updatePetObservableEvents); + +// Synthetic proofs for the void variants. petstore-rich has no 204-returning +// operations; hand-declare instances against the interfaces exported from +// rest.util so the overload set is still asserted by the tsc gate. +declare const zeroArgVoidFactory: ZeroArgRequestFnVoid; +declare const requestVoidFactory: RequestFnVoid<{ id: string }>; + +expectType>(zeroArgVoidFactory.observable()); +expectType>>( + zeroArgVoidFactory.observable({ observe: 'response' }), +); +expectType>>( + zeroArgVoidFactory.observable({ observe: 'events' }), +); + +expectType>(requestVoidFactory.observable({ id: 'x' })); +expectType>>( + requestVoidFactory.observable({ id: 'x' }, { observe: 'response' }), +); +expectType>>( + requestVoidFactory.observable( + { id: 'x' }, + { observe: 'events', reportProgress: true }, + ), +); diff --git a/__test__/angular-consumer/tsconfig.bench-large.json b/__test__/angular-consumer/tsconfig.bench-large.json new file mode 100644 index 0000000..268edbb --- /dev/null +++ b/__test__/angular-consumer/tsconfig.bench-large.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["generated/**/*.ts"] +} diff --git a/__test__/angular-consumer/tsconfig.consumer.json b/__test__/angular-consumer/tsconfig.consumer.json new file mode 100644 index 0000000..f5d28a1 --- /dev/null +++ b/__test__/angular-consumer/tsconfig.consumer.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/consumer-proof.ts", "generated/**/*.ts"] +} diff --git a/__test__/angular-consumer/tsconfig.discriminator.json b/__test__/angular-consumer/tsconfig.discriminator.json new file mode 100644 index 0000000..0d79ca7 --- /dev/null +++ b/__test__/angular-consumer/tsconfig.discriminator.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/discriminator-proof.ts", "generated/**/*.ts"] +} diff --git a/__test__/angular-consumer/tsconfig.emit-target.json b/__test__/angular-consumer/tsconfig.emit-target.json new file mode 100644 index 0000000..e4df36c --- /dev/null +++ b/__test__/angular-consumer/tsconfig.emit-target.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@avsystem/openapi-ng": ["../../index.d.ts"] + } + }, + "include": ["src/emit-target-proof.ts"] +} diff --git a/__test__/angular-consumer/tsconfig.form-non-json.json b/__test__/angular-consumer/tsconfig.form-non-json.json new file mode 100644 index 0000000..b62e381 --- /dev/null +++ b/__test__/angular-consumer/tsconfig.form-non-json.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/form-non-json-proof.ts", "generated/**/*.ts"] +} diff --git a/__test__/angular-consumer/tsconfig.json b/__test__/angular-consumer/tsconfig.json new file mode 100644 index 0000000..bb3a59f --- /dev/null +++ b/__test__/angular-consumer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "preserve", + "moduleResolution": "bundler", + "target": "es2024", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true + } +} diff --git a/__test__/angular-consumer/tsconfig.negative-binary-field.json b/__test__/angular-consumer/tsconfig.negative-binary-field.json new file mode 100644 index 0000000..b7494a1 --- /dev/null +++ b/__test__/angular-consumer/tsconfig.negative-binary-field.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/negative-proof/binary-field-rejects-string.ts", + "generated/**/*.ts" + ] +} diff --git a/__test__/angular-consumer/tsconfig.negative.json b/__test__/angular-consumer/tsconfig.negative.json new file mode 100644 index 0000000..caffcb8 --- /dev/null +++ b/__test__/angular-consumer/tsconfig.negative.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/negative-proof/negative.ts", "generated/**/*.ts"] +} diff --git a/__test__/angular-consumer/tsconfig.service.json b/__test__/angular-consumer/tsconfig.service.json new file mode 100644 index 0000000..dbf73e4 --- /dev/null +++ b/__test__/angular-consumer/tsconfig.service.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/service-proof.ts", "src/base-path-proof.ts", "generated/**/*.ts"] +} diff --git a/__test__/browser.spec.ts b/__test__/browser.spec.ts new file mode 100644 index 0000000..40905cd --- /dev/null +++ b/__test__/browser.spec.ts @@ -0,0 +1,35 @@ +import test from 'ava'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const require = createRequire(import.meta.url); + +const browserEntry = require(path.join(repoRoot, 'browser.js')) as { + generate: (options?: unknown) => Promise; + GenerateError: { + isGenerateError: (value: unknown) => boolean; + new (payload?: unknown): Error & { code?: string; message: string }; + }; + EmitTarget: { Models: string; Angular: string }; +}; + +test('browser generate throws a GenerateError, not a plain Error', async t => { + const { generate, GenerateError } = browserEntry; + const err = await t.throwsAsync(async () => { + await generate({ inputPath: 'x', emit: ['models'] }); + }); + t.true(GenerateError.isGenerateError(err)); + t.is((err as { code?: string } | undefined)?.code, 'E_UNSUPPORTED_RUNTIME'); + // Be lenient on the message — the test originally expected /browser/i but + // the new wrapper says "browser/runtime context". + t.regex(err?.message ?? '', /browser|runtime/i); +}); + +test('browser entry exports EmitTarget mirror', t => { + t.truthy(browserEntry.EmitTarget); + t.is(browserEntry.EmitTarget.Models, 'models'); + t.is(browserEntry.EmitTarget.Angular, 'angular'); +}); diff --git a/__test__/cli-parse.spec.ts b/__test__/cli-parse.spec.ts new file mode 100644 index 0000000..9393cb7 --- /dev/null +++ b/__test__/cli-parse.spec.ts @@ -0,0 +1,838 @@ +import test from 'ava'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const require = createRequire(import.meta.url); + +type ParseModule = { + parseMappedType(value: string): { + schema: string; + import: string; + type: string; + alias?: string; + }; + discoverConfigPath(startDir: string): string | null; + loadConfigFile(configPath: string): Promise>; + normalizeMappedTypes(items: unknown): unknown[] | null; + normalizeEmit(value: unknown): string[] | null; + mergeConfig( + fileConfig: Record, + cliFlags: Record, + ): Record; + parseArgs(argv: string[]): Record; + DEFAULT_EMIT: readonly string[]; + CONFIG_FILENAMES: readonly string[]; +}; + +const parse = require(path.join(repoRoot, 'bin', 'lib', 'parse.js')) as ParseModule; + +async function withTempDir(run: (dir: string) => void | Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-ng-parse-')); + try { + await run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── parseMappedType ────────────────────────────────────────────────────────── + +test('parseMappedType accepts 3-part schema:import:type', t => { + t.deepEqual(parse.parseMappedType('PetId:@demo/types:ExternalPetId'), { + schema: 'PetId', + import: '@demo/types', + type: 'ExternalPetId', + alias: undefined, + }); +}); + +test('parseMappedType accepts 4-part schema:import:type:alias', t => { + t.deepEqual(parse.parseMappedType('PetId:@demo/types:ExternalPetId:Alias'), { + schema: 'PetId', + import: '@demo/types', + type: 'ExternalPetId', + alias: 'Alias', + }); +}); + +test('parseMappedType rejects fewer than 3 parts with config-file hint', t => { + const error = t.throws(() => parse.parseMappedType('PetId:OnlyOne')); + t.true(error?.message.includes('Invalid --mapped-type')); + t.true(error?.message.includes('mappedTypes:')); + t.true(error?.message.includes('config file')); +}); + +test('parseMappedType rejects more than 4 parts (Windows-style absolute paths)', t => { + const error = t.throws(() => + parse.parseMappedType('PetId:C:\\Users\\x\\types:ExternalPetId:Alias'), + ); + t.true(error?.message.includes('Invalid --mapped-type')); + t.true(error?.message.includes('Windows absolute paths')); +}); + +test('parseMappedType rejects empty segments', t => { + const error = t.throws(() => parse.parseMappedType('PetId::ExternalPetId')); + t.true(error?.message.includes('Invalid --mapped-type')); +}); + +// ── normalizeMappedTypes ───────────────────────────────────────────────────── + +test('normalizeMappedTypes returns null for non-arrays', t => { + t.is(parse.normalizeMappedTypes(undefined), null); + t.is(parse.normalizeMappedTypes(null), null); + t.is(parse.normalizeMappedTypes('PetId:@demo:Type'), null); + t.is(parse.normalizeMappedTypes({}), null); +}); + +test('normalizeMappedTypes passes the schema/import/type/alias shape through unchanged', t => { + t.deepEqual( + parse.normalizeMappedTypes([ + { schema: 'PetId', import: '@demo', type: 'ExternalPetId', alias: 'Alias' }, + ]), + [ + { + schema: 'PetId', + import: '@demo', + type: 'ExternalPetId', + alias: 'Alias', + }, + ], + ); +}); + +test('normalizeMappedTypes leaves missing alias as undefined (not pulled from typeAlias)', t => { + t.deepEqual( + parse.normalizeMappedTypes([ + { schema: 'PetId', import: '@demo', type: 'ExternalPetId' }, + ]), + [ + { + schema: 'PetId', + import: '@demo', + type: 'ExternalPetId', + alias: undefined, + }, + ], + ); +}); + +// ── normalizeEmit ──────────────────────────────────────────────────────────── + +test('normalizeEmit returns null for null/undefined', t => { + t.is(parse.normalizeEmit(null), null); + t.is(parse.normalizeEmit(undefined), null); +}); + +test('normalizeEmit accepts YAML array form', t => { + t.deepEqual(parse.normalizeEmit(['models', 'angular']), ['models', 'angular']); + t.deepEqual(parse.normalizeEmit(['angular']), ['angular']); +}); + +test('normalizeEmit accepts CLI comma-separated string', t => { + t.deepEqual(parse.normalizeEmit('models,angular'), ['models', 'angular']); +}); + +test('normalizeEmit trims whitespace and drops empties', t => { + t.deepEqual(parse.normalizeEmit(' models , angular , '), ['models', 'angular']); +}); + +test('normalizeEmit dedupes while preserving order', t => { + t.deepEqual(parse.normalizeEmit('models,angular,models'), ['models', 'angular']); +}); + +test('normalizeEmit rejects unknown targets', t => { + const err = t.throws(() => parse.normalizeEmit(['models', 'react-query'])); + t.true(err?.message.includes("Unknown emit target: 'react-query'")); + t.true(err?.message.includes("'models'")); + t.true(err?.message.includes("'angular'")); +}); + +test('normalizeEmit rejects non-array, non-string values', t => { + const err = t.throws(() => parse.normalizeEmit({ angular: true })); + t.true(err?.message.includes('Invalid emit value')); +}); + +// ── mergeConfig ────────────────────────────────────────────────────────────── + +test('mergeConfig: cli flags override file config', t => { + const merged = parse.mergeConfig( + { input: 'file-input.yaml', output: 'file-out', emit: ['models', 'angular'] }, + { inputPath: 'cli-input.yaml', outputPath: 'cli-out', emit: ['models'] }, + ); + t.is(merged.inputPath, 'cli-input.yaml'); + t.is(merged.outputPath, 'cli-out'); + t.deepEqual(merged.emit, ['models']); +}); + +test('mergeConfig: file config fills gaps when cli flag absent', t => { + const merged = parse.mergeConfig( + { input: 'file-input.yaml', emit: ['models', 'angular'] }, + { inputPath: null, outputPath: null, emit: null }, + ); + t.is(merged.inputPath, 'file-input.yaml'); + t.deepEqual(merged.emit, ['models', 'angular']); +}); + +test('mergeConfig: defaults emit to models+angular when both file and cli absent', t => { + const merged = parse.mergeConfig({}, {}); + t.is(merged.inputPath, null); + t.is(merged.outputPath, null); + t.is(merged.verbose, false); + t.deepEqual(merged.emit, ['models', 'angular']); + t.is(merged.mappedTypes, null); +}); + +test('mergeConfig: cli emit wins over file emit', t => { + const merged = parse.mergeConfig({ emit: ['models'] }, { emit: ['angular'] }); + t.deepEqual(merged.emit, ['angular']); +}); + +test('mergeConfig: cli mappedTypes wins over file mappedTypes', t => { + const merged = parse.mergeConfig( + { + mappedTypes: [{ schema: 'A', import: 'a', type: 'A' }], + }, + { + mappedTypes: [{ schema: 'B', import: 'b', type: 'B', alias: undefined }], + }, + ); + t.deepEqual(merged.mappedTypes, [ + { schema: 'B', import: 'b', type: 'B', alias: undefined }, + ]); +}); + +test('mergeConfig: file mappedTypes flow through when no cli override', t => { + const merged = parse.mergeConfig( + { mappedTypes: [{ schema: 'PetId', import: '@demo', type: 'ExternalPetId' }] }, + { mappedTypes: null }, + ); + t.deepEqual(merged.mappedTypes, [ + { + schema: 'PetId', + import: '@demo', + type: 'ExternalPetId', + alias: undefined, + }, + ]); +}); + +test('mergeConfig: file responseTypeMapping flows through to merged config', t => { + const merged = parse.mergeConfig( + { + responseTypeMapping: [ + { contentType: 'application/octet-stream', responseType: 'blob' }, + { contentType: 'text/csv', responseType: 'text' }, + ], + }, + {}, + ); + t.deepEqual(merged.responseTypeMapping, [ + { contentType: 'application/octet-stream', responseType: 'blob' }, + { contentType: 'text/csv', responseType: 'text' }, + ]); +}); + +test('mergeConfig: responseTypeMapping is null when file omits it', t => { + const merged = parse.mergeConfig({}, {}); + t.is(merged.responseTypeMapping, null); +}); + +// ── parseArgs ──────────────────────────────────────────────────────────────── + +test('parseArgs returns kind=help for empty argv, --help, -h', t => { + t.is((parse.parseArgs([]) as { kind: string }).kind, 'help'); + t.is((parse.parseArgs(['--help']) as { kind: string }).kind, 'help'); + t.is((parse.parseArgs(['-h']) as { kind: string }).kind, 'help'); +}); + +test('parseArgs flags empty argv as non-explicit help (drives exit 2)', t => { + // Bare `openapi-ng` and explicit `--help` both classify as help, but the + // caller needs to distinguish them so CI scripts can catch a missing + // subcommand. The `explicit` field is that signal. + t.is((parse.parseArgs([]) as { explicit: boolean }).explicit, false); + t.is((parse.parseArgs(['--help']) as { explicit: boolean }).explicit, true); + t.is((parse.parseArgs(['-h']) as { explicit: boolean }).explicit, true); + t.is((parse.parseArgs(['generate', '--help']) as { explicit: boolean }).explicit, true); + t.is((parse.parseArgs(['init', '--help']) as { explicit: boolean }).explicit, true); +}); + +test('parseArgs returns kind=init for `init`', t => { + t.deepEqual(parse.parseArgs(['init']), { kind: 'init', format: 'yaml' }); +}); + +test('parseArgs init defaults format to yaml when --format is absent', t => { + t.like(parse.parseArgs(['init']), { kind: 'init', format: 'yaml' }); +}); + +test('parseArgs init accepts --format yaml', t => { + t.like(parse.parseArgs(['init', '--format', 'yaml']), { kind: 'init', format: 'yaml' }); +}); + +test('parseArgs init accepts --format json', t => { + t.like(parse.parseArgs(['init', '--format', 'json']), { kind: 'init', format: 'json' }); +}); + +test('parseArgs init accepts --format ts', t => { + t.like(parse.parseArgs(['init', '--format', 'ts']), { kind: 'init', format: 'ts' }); +}); + +test('parseArgs init accepts --format js', t => { + t.like(parse.parseArgs(['init', '--format', 'js']), { kind: 'init', format: 'js' }); +}); + +test('parseArgs init rejects unknown --format value', t => { + const err = t.throws(() => parse.parseArgs(['init', '--format', 'zsh'])); + t.regex(err!.message, /--format/); + t.regex(err!.message, /yaml.*json.*ts.*js/); +}); + +test('parseArgs init: --format requires a value', t => { + const err = t.throws(() => parse.parseArgs(['init', '--format'])); + t.regex(err!.message, /--format requires a value/); +}); + +test('parseArgs init: --format must immediately precede a non-flag value', t => { + const err = t.throws(() => parse.parseArgs(['init', '--format', '--help'])); + t.regex(err!.message, /--format requires a value/); +}); + +test('parseArgs throws for unsupported command', t => { + const error = t.throws(() => parse.parseArgs(['unknown-cmd'])); + t.true(error?.message.includes('Unsupported command')); +}); + +test('parseArgs throws for unsupported argument', t => { + const error = t.throws(() => parse.parseArgs(['generate', '--bogus'])); + t.true(error?.message.includes('Unsupported argument')); +}); + +test('parseArgs collects --input / --output (and short forms)', t => { + const long = parse.parseArgs(['generate', '--input', 'a.yaml', '--output', 'out']); + t.like(long, { kind: 'generate', inputPath: 'a.yaml', outputPath: 'out' }); + + const short = parse.parseArgs(['generate', '-i', 'b.yaml', '-o', 'out2']); + t.like(short, { kind: 'generate', inputPath: 'b.yaml', outputPath: 'out2' }); +}); + +test('parseArgs: --emit accepts comma-separated list', t => { + const result = parse.parseArgs(['generate', '--emit', 'models,angular']); + t.deepEqual(result.emit, ['models', 'angular']); +}); + +test('parseArgs: --emit is repeatable, entries dedupe', t => { + const result = parse.parseArgs([ + 'generate', + '--emit', + 'models', + '--emit', + 'angular,models', + ]); + t.deepEqual(result.emit, ['models', 'angular']); +}); + +test('parseArgs: --emit rejects unknown targets at parse time', t => { + const err = t.throws(() => + parse.parseArgs(['generate', '--emit', 'models,react-query']), + ); + t.true(err?.message.includes("Unknown emit target: 'react-query'")); +}); + +test('parseArgs: --verbose flag', t => { + const present = parse.parseArgs(['generate', '--verbose']); + t.is(present.verbose, true); + const absent = parse.parseArgs(['generate']); + t.is(absent.verbose, null); +}); + +test('parseArgs: emit absent yields null (not empty array)', t => { + const result = parse.parseArgs(['generate', '--input', 'a.yaml']); + t.is(result.emit, null); +}); + +test('parseArgs collects multiple --mapped-type into an array', t => { + const result = parse.parseArgs([ + 'generate', + '--mapped-type', + 'A:@demo:AType', + '--mapped-type', + 'B:@demo:BType:BAlias', + ]); + t.deepEqual(result.mappedTypes, [ + { schema: 'A', import: '@demo', type: 'AType', alias: undefined }, + { schema: 'B', import: '@demo', type: 'BType', alias: 'BAlias' }, + ]); +}); + +test('parseArgs: --mapped-type absent yields null (not empty array)', t => { + const result = parse.parseArgs(['generate', '--input', 'a.yaml']); + t.is(result.mappedTypes, null); +}); + +test('parseArgs extracts global --config before command', t => { + const result = parse.parseArgs([ + '--config', + '/tmp/cfg.yaml', + 'generate', + '--input', + 'a.yaml', + ]); + t.like(result, { kind: 'generate', configPath: '/tmp/cfg.yaml', inputPath: 'a.yaml' }); +}); + +test('parseArgs extracts global -c after command (interleaved)', t => { + const result = parse.parseArgs([ + 'generate', + '-c', + '/tmp/cfg.yaml', + '--input', + 'a.yaml', + ]); + t.like(result, { kind: 'generate', configPath: '/tmp/cfg.yaml', inputPath: 'a.yaml' }); +}); + +// ── Flag value validation (no silent token consumption) ───────────────────── + +test('parseArgs: --config errors when next token is another flag', t => { + const err = t.throws(() => parse.parseArgs(['--config', '--input', 'spec.yaml'])); + t.regex(err!.message, /--config requires a value/); +}); + +test('parseArgs: -c errors when next token is another flag', t => { + const err = t.throws(() => parse.parseArgs(['-c', '--input', 'spec.yaml'])); + t.regex(err!.message, /--config requires a value/); +}); + +test('parseArgs: --input errors when next token is another flag', t => { + const err = t.throws(() => parse.parseArgs(['generate', '--input', '--output', 'out'])); + t.regex(err!.message, /--input requires a value/); +}); + +test('parseArgs: -i errors when next token is another flag', t => { + const err = t.throws(() => parse.parseArgs(['generate', '-i', '--output', 'out'])); + t.regex(err!.message, /--input requires a value/); +}); + +test('parseArgs: --output errors when next token is missing', t => { + const err = t.throws(() => parse.parseArgs(['generate', '--output'])); + t.regex(err!.message, /--output requires a value/); +}); + +test('parseArgs: -o errors when next token starts with --', t => { + const err = t.throws(() => parse.parseArgs(['generate', '-o', '--verbose'])); + t.regex(err!.message, /--output requires a value/); +}); + +test('parseArgs: --emit errors when next token is another flag', t => { + const err = t.throws(() => + parse.parseArgs(['generate', '--emit', '--input', 'spec.yaml']), + ); + t.regex(err!.message, /--emit requires a value/); +}); + +test('parseArgs: --emit errors when next token is missing', t => { + const err = t.throws(() => parse.parseArgs(['generate', '--emit'])); + t.regex(err!.message, /--emit requires a value/); +}); + +test('parseArgs: --mapped-type errors when next token starts with --', t => { + const err = t.throws(() => + parse.parseArgs(['generate', '--mapped-type', '--input', 'spec.yaml']), + ); + t.regex(err!.message, /--mapped-type requires a value/); +}); + +test('parseArgs: --mapped-type errors when next token is missing', t => { + const err = t.throws(() => parse.parseArgs(['generate', '--mapped-type'])); + t.regex(err!.message, /--mapped-type requires a value/); +}); + +test('mergeConfig forwards file-config naming.methodName to the merged result', t => { + const merged = parse.mergeConfig( + { naming: { methodName: '{operationId}' } }, + { kind: 'generate', emit: null, mappedTypes: null }, + ); + t.deepEqual(merged.naming, { methodName: '{operationId}' }); +}); + +test('mergeConfig rejects naming.parse when not a RegExp (preserves YAML/JSON safety)', t => { + const fileConfig = { + naming: { methodName: { parse: 'literal-string-from-yaml' } }, + }; + const err = t.throws(() => parse.mergeConfig(fileConfig, {})); + t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID'); + t.regex(err!.message, /parse.*RegExp/i); +}); + +test('mergeConfig accepts RegExp on naming.parse (JS/TS config path)', t => { + const fileConfig = { + naming: { + methodName: { + from: '{operationId}', + parse: /^[^_]+_(?.+)$/, + format: '{capture.rest}', + case: 'camel', + }, + }, + }; + const merged = parse.mergeConfig(fileConfig, {}) as { + naming?: { methodName?: { parse?: RegExp } }; + }; + t.true(merged.naming?.methodName?.parse instanceof RegExp); + t.is(merged.naming?.methodName?.parse?.source, '^[^_]+_(?.+)$'); +}); + +test('mergeConfig rejects non-RegExp parse (e.g. string from YAML)', t => { + const fileConfig = { + naming: { + methodName: { + from: '{operationId}', + parse: '^[^_]+_(?.+)$', // a string, as YAML would deliver + }, + }, + }; + const err = t.throws(() => parse.mergeConfig(fileConfig, {})); + t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID'); + t.regex(err!.message, /parse.*RegExp/i); +}); + +// ── DEFAULT_EMIT ───────────────────────────────────────────────────────────── + +test('DEFAULT_EMIT is models+angular', t => { + t.deepEqual([...parse.DEFAULT_EMIT], ['models', 'angular']); +}); + +// ── discoverConfigPath ─────────────────────────────────────────────────────── + +test('discoverConfigPath returns null when no config exists in tree', async t => { + await withTempDir(async dir => { + // tmpdir parents may have a config; isolate by using an even-deeper temp. + const sub = fs.mkdtempSync(path.join(dir, 'isolated-')); + // We can only assert null if the result is null OR walks past dir. + // Make a strict check: if a config IS found, it must not be inside dir. + const found = parse.discoverConfigPath(sub); + if (found !== null) { + t.false(found.startsWith(dir), `unexpected config inside isolated dir: ${found}`); + } else { + t.pass(); + } + }); +}); + +test('discoverConfigPath finds .openapi-ng.yaml in startDir', async t => { + await withTempDir(async dir => { + const target = path.join(dir, '.openapi-ng.yaml'); + fs.writeFileSync(target, 'input: a.yaml\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath prefers .yaml over .json when both present', async t => { + await withTempDir(async dir => { + fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: a\n', 'utf8'); + fs.writeFileSync(path.join(dir, '.openapi-ng.json'), '{"input":"b"}', 'utf8'); + t.is(parse.discoverConfigPath(dir), path.join(dir, '.openapi-ng.yaml')); + }); +}); + +test('discoverConfigPath falls back to .openapi-ng.json when .yaml absent', async t => { + await withTempDir(async dir => { + const target = path.join(dir, '.openapi-ng.json'); + fs.writeFileSync(target, '{"input":"a.yaml"}', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath walks up the directory tree', async t => { + await withTempDir(async dir => { + const target = path.join(dir, '.openapi-ng.yaml'); + fs.writeFileSync(target, 'input: a\n', 'utf8'); + const nested = path.join(dir, 'a', 'b', 'c'); + fs.mkdirSync(nested, { recursive: true }); + t.is(parse.discoverConfigPath(nested), target); + }); +}); + +test('discoverConfigPath finds openapi-ng.config.ts in startDir', async t => { + await withTempDir(async dir => { + const target = path.join(dir, 'openapi-ng.config.ts'); + fs.writeFileSync(target, 'export default {}\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath finds openapi-ng.config.mts in startDir', async t => { + await withTempDir(async dir => { + const target = path.join(dir, 'openapi-ng.config.mts'); + fs.writeFileSync(target, 'export default {}\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath finds openapi-ng.config.cts in startDir', async t => { + await withTempDir(async dir => { + const target = path.join(dir, 'openapi-ng.config.cts'); + fs.writeFileSync(target, 'module.exports = {}\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath finds openapi-ng.config.mjs in startDir', async t => { + await withTempDir(async dir => { + const target = path.join(dir, 'openapi-ng.config.mjs'); + fs.writeFileSync(target, 'export default {}\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath finds openapi-ng.config.js in startDir', async t => { + await withTempDir(async dir => { + const target = path.join(dir, 'openapi-ng.config.js'); + fs.writeFileSync(target, 'module.exports = {}\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath finds openapi-ng.config.cjs in startDir', async t => { + await withTempDir(async dir => { + const target = path.join(dir, 'openapi-ng.config.cjs'); + fs.writeFileSync(target, 'module.exports = {}\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), target); + }); +}); + +test('discoverConfigPath prefers openapi-ng.config.ts over plain .js when both present', async t => { + await withTempDir(async dir => { + fs.writeFileSync( + path.join(dir, 'openapi-ng.config.ts'), + 'export default {}\n', + 'utf8', + ); + fs.writeFileSync( + path.join(dir, 'openapi-ng.config.js'), + 'module.exports = {}\n', + 'utf8', + ); + t.is(parse.discoverConfigPath(dir), path.join(dir, 'openapi-ng.config.ts')); + }); +}); + +test('discoverConfigPath prefers openapi-ng.config.js over legacy .openapi-ng.yaml', async t => { + await withTempDir(async dir => { + fs.writeFileSync( + path.join(dir, 'openapi-ng.config.js'), + 'module.exports = {}\n', + 'utf8', + ); + fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: a\n', 'utf8'); + t.is(parse.discoverConfigPath(dir), path.join(dir, 'openapi-ng.config.js')); + }); +}); + +test('discoverConfigPath walks up to find openapi-ng.config.ts in parent', async t => { + await withTempDir(async dir => { + const target = path.join(dir, 'openapi-ng.config.ts'); + fs.writeFileSync(target, 'export default {}\n', 'utf8'); + const nested = path.join(dir, 'a', 'b', 'c'); + fs.mkdirSync(nested, { recursive: true }); + t.is(parse.discoverConfigPath(nested), target); + }); +}); + +// ── loadConfigFile ─────────────────────────────────────────────────────────── + +test('loadConfigFile parses YAML files with array-form emit', async t => { + await withTempDir(async dir => { + const p = path.join(dir, '.openapi-ng.yaml'); + fs.writeFileSync(p, 'input: a.yaml\nemit:\n - models\n - angular\n', 'utf8'); + t.deepEqual(await parse.loadConfigFile(p), { + input: 'a.yaml', + emit: ['models', 'angular'], + }); + }); +}); + +test('loadConfigFile parses JSON files', async t => { + await withTempDir(async dir => { + const p = path.join(dir, '.openapi-ng.json'); + fs.writeFileSync(p, '{"input":"a.yaml","emit":["models","angular"]}', 'utf8'); + t.deepEqual(await parse.loadConfigFile(p), { + input: 'a.yaml', + emit: ['models', 'angular'], + }); + }); +}); + +test('loadConfigFile returns {} for empty YAML', async t => { + await withTempDir(async dir => { + const p = path.join(dir, '.openapi-ng.yaml'); + fs.writeFileSync(p, '', 'utf8'); + t.deepEqual(await parse.loadConfigFile(p), {}); + }); +}); + +test('loadConfigFile is case-insensitive on extension (.JSON treated as json)', async t => { + await withTempDir(async dir => { + const p = path.join(dir, '.openapi-ng.JSON'); + fs.writeFileSync(p, '{"input":"a.yaml"}', 'utf8'); + t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml' }); + }); +}); + +test('loadConfigFile loads .cjs with module.exports = object', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.cjs'); + fs.writeFileSync(p, "module.exports = { input: 'a.yaml', output: 'o' };\n", 'utf8'); + t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' }); + }); +}); + +test('loadConfigFile loads .mjs with export default object', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mjs'); + fs.writeFileSync(p, "export default { input: 'a.yaml', output: 'o' };\n", 'utf8'); + t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' }); + }); +}); + +test('loadConfigFile loads .js (CJS) with module.exports', async t => { + await withTempDir(async dir => { + // A .js file in a tmpdir with no package.json defaults to CJS under + // Node's resolution rules — `module.exports = ...` is the right form. + const p = path.join(dir, 'openapi-ng.config.js'); + fs.writeFileSync(p, "module.exports = { input: 'a.yaml', output: 'o' };\n", 'utf8'); + t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' }); + }); +}); + +test('loadConfigFile awaits an async-function default export', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mjs'); + fs.writeFileSync( + p, + "export default async () => ({ input: 'a.yaml', output: 'o' });\n", + 'utf8', + ); + t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml', output: 'o' }); + }); +}); + +test('loadConfigFile rejects a JS file with no default export', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mjs'); + // No default export — just a named export. + fs.writeFileSync(p, 'export const x = 1;\n', 'utf8'); + const err = await t.throwsAsync(() => parse.loadConfigFile(p)); + t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID'); + t.regex(err!.message, /no default export/i); + }); +}); + +test('loadConfigFile rejects a JS file whose default export is null', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mjs'); + fs.writeFileSync(p, 'export default null;\n', 'utf8'); + const err = await t.throwsAsync(() => parse.loadConfigFile(p)); + t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID'); + t.regex(err!.message, /default export must be an object/i); + }); +}); + +test('loadConfigFile rejects a JS file whose default export is a primitive', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mjs'); + fs.writeFileSync(p, 'export default 42;\n', 'utf8'); + const err = await t.throwsAsync(() => parse.loadConfigFile(p)); + t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID'); + t.regex(err!.message, /default export must be an object/i); + }); +}); + +test('loadConfigFile rejects a JS file whose default export is an array', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mjs'); + fs.writeFileSync(p, "export default ['models'];\n", 'utf8'); + const err = await t.throwsAsync(() => parse.loadConfigFile(p)); + t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID'); + t.regex(err!.message, /default export must be an object/i); + }); +}); + +test('loadConfigFile wraps module-load errors as E_INPUT_INVALID', async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mjs'); + // Syntax error at module top — Node's loader throws during import. + fs.writeFileSync(p, 'export default { broken\n', 'utf8'); + const err = await t.throwsAsync(() => parse.loadConfigFile(p)); + t.is((err as NodeJS.ErrnoException).code, 'E_INPUT_INVALID'); + t.regex(err!.message, /Failed to load config file/i); + }); +}); + +// On Windows + Node 20 the @oxc-node/core/register hook fails inside its +// own native binding with "Missing field `format`" before our mapping +// branch ever sees ERR_UNKNOWN_FILE_EXTENSION, so the canonical two-outcome +// contract this test guards doesn't apply on that combo. The same loader +// works fine on Linux/macOS Node 20 and on Windows Node 22+, so skipping +// only the affected matrix entry preserves coverage everywhere else. +const skipCtsLoaderTest = + process.platform === 'win32' && Number(process.versions.node.split('.')[0]) < 22; +(skipCtsLoaderTest ? test.skip : test)( + 'loadConfigFile maps ERR_UNKNOWN_FILE_EXTENSION on .cts to a version hint', + async t => { + // Two-outcome contract: on Node ≥ 22.6 native TS stripping handles + // the .cts file and the load succeeds; on Node < 22.6 the loader + // throws ERR_UNKNOWN_FILE_EXTENSION and our mapping branch turns it + // into a friendly E_INPUT_INVALID. Inside the AVA worker the + // @oxc-node/core/register hook handles TS files itself, so the + // success branch fires; in production with a vanilla Node binary + // the branch chosen depends on the running version. Either outcome + // proves we never surface a generic "Failed to load" wrap for a + // .cts file when the underlying error is ERR_UNKNOWN_FILE_EXTENSION. + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.cts'); + fs.writeFileSync( + p, + "const cfg: { input: string } = { input: 'a.yaml' };\nmodule.exports = cfg;\n", + 'utf8', + ); + try { + const result = await parse.loadConfigFile(p); + // Modern Node — strip-types worked. + t.deepEqual(result, { input: 'a.yaml' }); + } catch (err) { + const e = err as NodeJS.ErrnoException; + t.is(e.code, 'E_INPUT_INVALID'); + t.regex(e.message, /TypeScript config files require Node 22\.6\+/i); + t.regex(e.message, /--experimental-strip-types|23\.6/i); + } + }); + }, +); + +// Smoke test: only meaningful on Node ≥ 22.6 where native TS stripping +// is available. Skipped otherwise so old-Node CI runners stay green. +// Uses .mts with `export default` syntax — strip-friendly because +// Node's --experimental-strip-types only removes type annotations and +// leaves valid ESM; no transpilation of TS-specific syntax needed. +const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number); +const tsNativeAvailable = nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 6); + +(tsNativeAvailable ? test : test.skip)( + 'loadConfigFile loads .mts with native TS stripping (Node ≥ 22.6)', + async t => { + await withTempDir(async dir => { + const p = path.join(dir, 'openapi-ng.config.mts'); + fs.writeFileSync( + p, + "interface C { input: string }\nexport default { input: 'a.yaml' } satisfies C;\n", + 'utf8', + ); + t.deepEqual(await parse.loadConfigFile(p), { input: 'a.yaml' }); + }); + }, +); diff --git a/__test__/cli.spec.ts b/__test__/cli.spec.ts new file mode 100644 index 0000000..a70e901 --- /dev/null +++ b/__test__/cli.spec.ts @@ -0,0 +1,794 @@ +import test from 'ava'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const fixture = (name: string) => path.join(repoRoot, 'test', 'fixtures', name); +const cliPath = path.join(repoRoot, 'bin', 'openapi-ng.js'); + +function runCli( + args: string[], + cwd = repoRoot, + options: { env?: NodeJS.ProcessEnv } = {}, +) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd, + encoding: 'utf8', + ...(options.env !== undefined ? { env: options.env } : {}), + }); +} + +function withTempDir(run: (dir: string) => void) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-ng-cli-')); + try { + run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ── Success: stdout format ────────────────────────────────────────────────── + +test('cli generate prints human-readable summary with title and file list', t => { + withTempDir(outputPath => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-rich.openapi.yaml'), + '--output', + outputPath, + ]); + + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('Generated 4 files from Petstore Rich')); + t.true(result.stdout.includes('2 paths')); + t.true(result.stdout.includes('3 operations')); + t.true(result.stdout.includes('6 schemas')); + t.true(result.stdout.includes('model.generated.ts')); + t.true(result.stdout.includes('rest.model.ts')); + t.true(result.stdout.includes('rest.util.ts')); + t.true(result.stdout.includes('rest/pet.rest.generated.ts')); + }); +}); + +test('cli generate prints summary for JSON fixture (JSON/YAML determinism)', t => { + withTempDir(yamlOut => { + withTempDir(jsonOut => { + const yamlResult = runCli([ + 'generate', + '--input', + fixture('petstore-rich.openapi.yaml'), + '--output', + yamlOut, + ]); + const jsonResult = runCli([ + 'generate', + '--input', + fixture('petstore-rich.openapi.json'), + '--output', + jsonOut, + ]); + t.is(yamlResult.status, 0); + t.is(jsonResult.status, 0); + // Both summaries describe the same spec + t.true(yamlResult.stdout.includes('Petstore Rich')); + t.true(jsonResult.stdout.includes('Petstore Rich')); + }); + }); +}); + +test('cli generate prints summary without --output (in-memory, no file writing)', t => { + withTempDir(tmpDir => { + const result = runCli( + ['generate', '--input', fixture('petstore-minimal.openapi.yaml')], + tmpDir, + ); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('Generated')); + t.true(result.stdout.includes('Petstore Minimal')); + // No files should be written since --output was not provided + t.deepEqual(fs.readdirSync(tmpDir), []); + }); +}); + +// ── Success: files on disk ────────────────────────────────────────────────── + +test('cli generate writes model and service files to --output dir', t => { + withTempDir(outputPath => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-rich.openapi.yaml'), + '--output', + outputPath, + ]); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest.model.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest.util.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet.rest.generated.ts'))); + }); +}); + +test('cli generate --emit angular auto-includes models (with warning under --verbose)', t => { + withTempDir(outputPath => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--output', + outputPath, + '--emit', + 'angular', + '--verbose', + ]); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); + t.true(result.stdout.includes("Auto-included 'models'")); + t.true(result.stdout.includes('E_INVALID_OPTION')); + }); +}); + +test('cli generate --mapped-type replaces schema with import', t => { + withTempDir(outputPath => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-rich.openapi.yaml'), + '--output', + outputPath, + '--mapped-type', + 'PetId:@demo/types:ExternalPetId', + ]); + t.is(result.status, 0); + t.is(result.stderr, ''); + const modelContents = fs.readFileSync( + path.join(outputPath, 'model.generated.ts'), + 'utf8', + ); + t.true(modelContents.includes("import type { ExternalPetId } from '@demo/types'")); + t.true(modelContents.includes('ExternalPetId')); + t.false(modelContents.includes('export type PetId = string;')); + }); +}); + +test('cli generate writes 3 artifacts for fixture without operations', t => { + withTempDir(outputPath => { + const result = runCli([ + 'generate', + '--input', + fixture('empty-shapes.openapi.yaml'), + '--output', + outputPath, + ]); + t.is(result.status, 0); + t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest.model.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest.util.ts'))); + t.false(fs.existsSync(path.join(outputPath, 'rest'))); + }); +}); + +// ── Verbose: warnings ────────────────────────────────────────────────────── + +test('cli generate suppresses warnings without --verbose', t => { + // cookie-param emits a non-fatal warning (cookies aren't surfaced in the + // generated service contract — browsers manage cookies via the cookie + // store). header-param used to share this behaviour but headers are now + // first-class. + const result = runCli(['generate', '--input', fixture('cookie-param.openapi.yaml')]); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.false(result.stdout.includes('Warnings')); + t.false(result.stdout.includes('E_UNSUPPORTED_SEMANTIC')); +}); + +test('cli generate --verbose prints warnings with code and operationId', t => { + const result = runCli([ + 'generate', + '--input', + fixture('cookie-param.openapi.yaml'), + '--verbose', + ]); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('Warnings (1):')); + t.true(result.stdout.includes('[E_UNSUPPORTED_SEMANTIC]')); + t.true(result.stdout.includes("operationId 'listPets'")); + t.true(result.stdout.includes("'sessionId'")); + t.true(result.stdout.includes("'cookie'")); +}); + +// ── Errors: stderr format & exit code ────────────────────────────────────── + +test('cli generate exits 1 with human-readable error for unsupported semantic', t => { + const result = runCli([ + 'generate', + '--input', + fixture('unsupported-semantic.openapi.yaml'), + ]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('E_UNSUPPORTED_SEMANTIC')); + t.true(result.stderr.includes('unsupported-semantic.openapi.yaml')); +}); + +test('cli generate exits 1 with human-readable error for malformed YAML', t => { + const result = runCli(['generate', '--input', fixture('malformed.yaml')]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.length > 0); +}); + +test('cli generate exits 1 with human-readable error for unsupported root shape', t => { + const result = runCli(['generate', '--input', fixture('unsupported-root.yaml')]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('E_INPUT_INVALID')); + t.true(result.stderr.includes('decode')); +}); + +// ── Argument parsing errors ───────────────────────────────────────────────── + +test('cli generate exits 1 with readable error when --input is missing', t => { + const result = runCli(['generate']); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('E_INVALID_OPTION')); + t.true(result.stderr.includes('--input')); +}); + +test('cli generate exits 1 with readable error for unknown argument', t => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--bogus', + ]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('E_INVALID_OPTION')); +}); + +test('cli generate exits 1 with readable error for malformed --mapped-type', t => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--mapped-type', + 'bad-format', + ]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('E_INVALID_OPTION')); +}); + +test('cli generate exits 1 with helpful error for --mapped-type with too many colons', t => { + // Windows-style absolute import paths like C:\some\path collide with the + // colon-delimited CLI surface. The error message must point users at the + // YAML/JSON config file as the supported workaround (C1). + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--mapped-type', + 'PetId:C:\\Users\\x\\types:ExternalPetId:Alias', + ]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('Invalid --mapped-type')); + t.true(result.stderr.includes('mappedTypes:')); + t.true(result.stderr.includes('config file')); +}); + +test('cli generate exits 1 with readable error for --mapped-type with empty segment', t => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--mapped-type', + 'PetId::ExternalPetId', + ]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('Invalid --mapped-type')); +}); + +test('cli prints usage for --help', t => { + const result = runCli(['--help']); + t.is(result.status, 0); + t.true(result.stdout.includes('Usage')); + t.true(result.stdout.includes('--input')); +}); + +test('cli with no args prints help to stdout and exits 2', t => { + // Bare `openapi-ng` should not silently succeed: CI scripts like + // `openapi-ng generate ... && next-step` would otherwise run `next-step` + // if the `generate` argv got eaten. Exit 2 is the conventional code for + // a usage error (matches GNU getopt, argparse, etc.). + const result = runCli([]); + t.is(result.status, 2); + t.regex(result.stdout, /Usage:/); +}); + +test('cli --help still exits 0', t => { + // Pin the existing behaviour so the "no args" change does not bleed + // into the explicit-help path. + const result = runCli(['--help']); + t.is(result.status, 0); +}); + +test('cli --version prints the package version and exits 0', t => { + const result = runCli(['--version']); + t.is(result.status, 0); + t.regex(result.stdout, /^\d+\.\d+\.\d+/); + t.is(result.stderr, ''); +}); + +test('cli generate --help prints subcommand-specific usage with every flag', t => { + const result = runCli(['generate', '--help']); + t.is(result.status, 0); + t.is(result.stderr, ''); + // Mentions the subcommand by name so users know they got per-subcommand help. + t.true(result.stdout.toLowerCase().includes('generate')); + // Every documented flag must appear in the per-subcommand help. + for (const flag of [ + '--input', + '--output', + '--emit', + '--mapped-type', + '--verbose', + '--config', + '--help', + ]) { + t.true( + result.stdout.includes(flag), + `generate --help must list ${flag}; got:\n${result.stdout}`, + ); + } +}); + +test('cli generate -h is accepted as an alias for --help', t => { + const result = runCli(['generate', '-h']); + t.is(result.status, 0); + t.true(result.stdout.includes('--input')); +}); + +test('cli --help output includes an Examples section', t => { + const result = runCli(['--help']); + t.is(result.status, 0); + t.regex(result.stdout, /Examples:/); + t.regex(result.stdout, /openapi-ng init/); + t.regex(result.stdout, /openapi-ng generate/); +}); + +test('cli generate --help output includes an Examples section', t => { + const result = runCli(['generate', '--help']); + t.is(result.status, 0); + t.regex(result.stdout, /Examples:/); + t.regex(result.stdout, /openapi-ng generate/); +}); + +test('cli init --help prints subcommand-specific usage', t => { + const result = runCli(['init', '--help']); + t.is(result.status, 0); + t.true(result.stdout.toLowerCase().includes('init')); +}); + +test('cli prints usage for unknown command', t => { + const result = runCli(['unknown-command']); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.includes('E_INVALID_OPTION')); +}); + +// ── init command ────────────────────────────────────────────────────────────── + +test('cli init creates default config file in cwd', t => { + withTempDir(dir => { + const result = runCli(['init'], dir); + t.is(result.status, 0); + t.is(result.stderr, ''); + const configPath = path.join(dir, '.openapi-ng.yaml'); + t.true(fs.existsSync(configPath)); + const contents = fs.readFileSync(configPath, 'utf8'); + t.true(contents.includes('input:')); + t.true(contents.includes('output:')); + t.true(contents.includes('emit:')); + }); +}); + +test('cli init does not overwrite existing config file', t => { + withTempDir(dir => { + const configPath = path.join(dir, '.openapi-ng.yaml'); + fs.writeFileSync(configPath, 'input: custom.yaml\n', 'utf8'); + const result = runCli(['init'], dir); + t.not(result.status, 0); + t.regex(result.stdout + result.stderr, /Cannot init: existing config file/i); + const contents = fs.readFileSync(configPath, 'utf8'); + t.is(contents, 'input: custom.yaml\n'); + }); +}); + +test('cli init --format yaml writes .openapi-ng.yaml (default behaviour preserved)', t => { + withTempDir(dir => { + const result = runCli(['init'], dir); + t.is(result.status, 0); + t.true(fs.existsSync(path.join(dir, '.openapi-ng.yaml'))); + const contents = fs.readFileSync(path.join(dir, '.openapi-ng.yaml'), 'utf8'); + t.regex(contents, /^# openapi-ng configuration/m); + t.regex(contents, /^input:/m); + }); +}); + +test('cli init --format json writes .openapi-ng.json', t => { + withTempDir(dir => { + const result = runCli(['init', '--format', 'json'], dir); + t.is(result.status, 0); + const target = path.join(dir, '.openapi-ng.json'); + t.true(fs.existsSync(target)); + const parsed = JSON.parse(fs.readFileSync(target, 'utf8')); + t.truthy(parsed.input); + t.truthy(parsed.output); + }); +}); + +test('cli init --format ts writes openapi-ng.config.mts with defineConfig + RegExp example', t => { + withTempDir(dir => { + const result = runCli(['init', '--format', 'ts'], dir); + t.is(result.status, 0); + const target = path.join(dir, 'openapi-ng.config.mts'); + t.true(fs.existsSync(target)); + t.false(fs.existsSync(path.join(dir, 'openapi-ng.config.ts'))); + const contents = fs.readFileSync(target, 'utf8'); + t.regex(contents, /import \{ defineConfig \} from '@avsystem\/openapi-ng\/config'/); + t.regex(contents, /export default defineConfig\(\{/); + t.regex(contents, /parse: \/\^\[\^_\]\+_\(\?.+\)\$\//); + }); +}); + +test('cli init --format js writes openapi-ng.config.mjs with JSDoc @type', t => { + withTempDir(dir => { + const result = runCli(['init', '--format', 'js'], dir); + t.is(result.status, 0); + const target = path.join(dir, 'openapi-ng.config.mjs'); + t.false(fs.existsSync(path.join(dir, 'openapi-ng.config.js'))); + t.true(fs.existsSync(target)); + const contents = fs.readFileSync(target, 'utf8'); + t.regex(contents, /@type \{import\('@avsystem\/openapi-ng'\)\.Config\}/); + }); +}); + +test('cli init --format ts aborts when .openapi-ng.yaml exists (cross-format)', t => { + withTempDir(dir => { + fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: a\n', 'utf8'); + const result = runCli(['init', '--format', 'ts'], dir); + t.not(result.status, 0); + t.regex(result.stdout + result.stderr, /existing config file/i); + t.regex(result.stdout + result.stderr, /\.openapi-ng\.yaml/); + t.false(fs.existsSync(path.join(dir, 'openapi-ng.config.mts'))); + }); +}); + +test('cli init --format yaml aborts when openapi-ng.config.ts exists (cross-format)', t => { + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, 'openapi-ng.config.ts'), + 'export default {}\n', + 'utf8', + ); + const result = runCli(['init', '--format', 'yaml'], dir); + t.not(result.status, 0); + t.regex(result.stdout + result.stderr, /openapi-ng\.config\.ts/); + }); +}); + +test('cli init aborts on any of the eight discoverable config names', t => { + // Spot-check three at once: json, cjs, and mts each must block init. + const names = ['.openapi-ng.json', 'openapi-ng.config.cjs', 'openapi-ng.config.mts']; + for (const existing of names) { + withTempDir(dir => { + fs.writeFileSync(path.join(dir, existing), 'placeholder\n', 'utf8'); + const result = runCli(['init', '--format', 'ts'], dir); + t.not(result.status, 0, `init should have aborted with ${existing} present`); + t.regex(result.stdout + result.stderr, new RegExp(existing.replace(/\./g, '\\.'))); + }); + } +}); + +test('cli init --format zsh errors with allow-list', t => { + withTempDir(dir => { + const result = runCli(['init', '--format', 'zsh'], dir); + t.not(result.status, 0); + t.regex(result.stderr, /--format/); + t.regex(result.stderr, /yaml.*json.*ts.*js/); + }); +}); + +test('cli init --format js writes ESM template regardless of package.json#type', t => { + for (const pkgJson of ['{"type":"module"}', '{"type":"commonjs"}', '{"name":"x"}']) { + withTempDir(dir => { + fs.writeFileSync(path.join(dir, 'package.json'), pkgJson, 'utf8'); + const result = runCli(['init', '--format', 'js'], dir); + t.is(result.status, 0); + t.true(fs.existsSync(path.join(dir, 'openapi-ng.config.mjs'))); + const contents = fs.readFileSync(path.join(dir, 'openapi-ng.config.mjs'), 'utf8'); + t.regex(contents, /^export default \{/m); + t.notRegex(contents, /module\.exports/); + }); + } +}); + +// ── config file ─────────────────────────────────────────────────────────────── + +test('cli generate reads input from config file', t => { + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, '.openapi-ng.yaml'), + `input: ${fixture('petstore-minimal.openapi.yaml')}\n`, + 'utf8', + ); + const result = runCli(['generate'], dir); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('Petstore Minimal')); + }); +}); + +test('cli generate --input overrides config file input', t => { + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, '.openapi-ng.yaml'), + 'input: nonexistent.yaml\n', + 'utf8', + ); + const result = runCli( + ['generate', '--input', fixture('petstore-minimal.openapi.yaml')], + dir, + ); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('Petstore Minimal')); + }); +}); + +test('cli generate reads array-form emit from config file', t => { + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, '.openapi-ng.yaml'), + [ + `input: ${fixture('petstore-rich.openapi.yaml')}`, + `output: ${dir}`, + 'emit:', + ' - models', + ' - angular', + ].join('\n') + '\n', + 'utf8', + ); + const result = runCli(['generate'], dir); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('model.generated.ts')); + t.true(fs.existsSync(path.join(dir, 'model.generated.ts'))); + t.true(fs.existsSync(path.join(dir, 'rest', 'pet.rest.generated.ts'))); + }); +}); + +test('cli generate --config uses explicit config path', t => { + withTempDir(dir => { + const customConfig = path.join(dir, 'custom-config.yaml'); + fs.writeFileSync( + customConfig, + `input: ${fixture('petstore-minimal.openapi.yaml')}\n`, + 'utf8', + ); + const result = runCli(['generate', '--config', customConfig], dir); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('Petstore Minimal')); + }); +}); + +test('cli generate reads mappedTypes from config file', t => { + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, '.openapi-ng.yaml'), + [ + `input: ${fixture('petstore-rich.openapi.yaml')}`, + `output: ${dir}`, + 'mappedTypes:', + ' - schema: PetId', + " import: '@demo/types'", + ' type: ExternalPetId', + ].join('\n') + '\n', + 'utf8', + ); + const result = runCli(['generate'], dir); + t.is(result.status, 0); + t.is(result.stderr, ''); + const modelContents = fs.readFileSync(path.join(dir, 'model.generated.ts'), 'utf8'); + t.true(modelContents.includes("import type { ExternalPetId } from '@demo/types'")); + t.false(modelContents.includes('export type PetId = string;')); + }); +}); + +test('cli generate ignores absent config file', t => { + withTempDir(dir => { + const result = runCli( + ['generate', '--input', fixture('petstore-minimal.openapi.yaml')], + dir, + ); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(result.stdout.includes('Petstore Minimal')); + }); +}); + +test('cli generate reads input from openapi-ng.config.ts (end-to-end)', t => { + // Smoke test gated on native TS support; equivalent .mjs test below covers + // older Node so the contract is verified somewhere on every CI matrix entry. + const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number); + if (!(nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 6))) { + t.pass('skipped: Node native TS stripping requires 22.6+'); + return; + } + withTempDir(dir => { + // Node's ESM loader requires a file:// URL or a forward-slash specifier; + // a raw Windows path like 'D:\\…/lib/config.js' is interpreted as scheme + // 'd:' and rejected with "Only URLs with a scheme in: file, data, and + // node are supported". + const configImport = pathToFileURL(path.join(repoRoot, 'lib', 'config.js')).href; + fs.writeFileSync( + path.join(dir, 'openapi-ng.config.ts'), + `import { defineConfig } from '${configImport}';\n` + + `export default defineConfig({\n` + + ` input: ${JSON.stringify(fixture('petstore-rich.openapi.yaml'))},\n` + + ` output: ${JSON.stringify(path.join(dir, 'out'))},\n` + + `});\n`, + 'utf8', + ); + const result = runCli(['generate'], dir); + t.is(result.status, 0, result.stderr); + t.true(fs.existsSync(path.join(dir, 'out', 'model.generated.ts'))); + }); +}); + +test('cli generate reads input from openapi-ng.config.mjs (end-to-end)', t => { + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, 'openapi-ng.config.mjs'), + `export default {\n` + + ` input: ${JSON.stringify(fixture('petstore-rich.openapi.yaml'))},\n` + + ` output: ${JSON.stringify(path.join(dir, 'out'))},\n` + + `};\n`, + 'utf8', + ); + const result = runCli(['generate'], dir); + t.is(result.status, 0, result.stderr); + t.true(fs.existsSync(path.join(dir, 'out', 'model.generated.ts'))); + }); +}); + +test('cli generate honours naming.parse RegExp from openapi-ng.config.mjs', t => { + // RegExp is the killer feature — we can express it now that JS/TS + // configs exist. The fixture's operationIds don't have an underscore + // prefix to strip, so the rule must fail gracefully and the fallback + // (the second rule, which always matches) provides the method name. + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, 'openapi-ng.config.mjs'), + `export default {\n` + + ` input: ${JSON.stringify(fixture('petstore-rich.openapi.yaml'))},\n` + + ` output: ${JSON.stringify(path.join(dir, 'out'))},\n` + + ` naming: {\n` + + ` methodName: [\n` + + ` { from: '{operationId}', parse: /^[^_]+_(?.+)$/, format: '{capture.rest}', case: 'camel' },\n` + + ` '{operationId}',\n` + + ` ],\n` + + ` },\n` + + `};\n`, + 'utf8', + ); + const result = runCli(['generate'], dir); + t.is(result.status, 0, result.stderr); + }); +}); + +// ── Async-main rejection guard ──────────────────────────────────────────────── + +test('cli generate emits a clean error (not "unhandled rejection") when --config path is missing', t => { + // Forces loadConfigFile to throw ENOENT. The inner try/catch in main + // catches this today; the top-level .catch is the defense-in-depth + // fallback for any future await in main that escapes a try/catch. + // Contract under either path: exit 1, single readable stderr line, no + // Node "[UnhandledPromiseRejection]" / multi-line stack dump. + const result = runCli([ + 'generate', + '--config', + '/definitely/nonexistent/openapi-ng.yaml', + ]); + t.is(result.status, 1); + t.is(result.stdout, ''); + t.true(result.stderr.length > 0, 'expected a readable error on stderr'); + t.false( + result.stderr.includes('UnhandledPromiseRejection'), + `stderr leaked an unhandled-rejection header: ${result.stderr}`, + ); + t.false( + result.stderr.includes('node:internal'), + `stderr leaked an internal Node stack frame: ${result.stderr}`, + ); +}); + +test('cli --config errors with E_INPUT_INVALID and a clean message', t => { + const result = runCli(['generate', '--config', '/nonexistent/path-for-task-3-2.yaml']); + t.is(result.status, 1); + t.regex(result.stderr, /Error \[E_INPUT_INVALID\]/); + t.regex(result.stderr, /config file not found/i); + t.notRegex(result.stderr, /ENOENT/); +}); + +test('cli tags YAML parse errors in config as E_INPUT_INVALID', t => { + withTempDir(dir => { + fs.writeFileSync(path.join(dir, '.openapi-ng.yaml'), 'input: [unbalanced\n', 'utf8'); + const result = runCli(['generate'], dir); + t.is(result.status, 1); + t.regex(result.stderr, /Error \[E_INPUT_INVALID\]/); + t.notRegex(result.stderr, /E_INVALID_OPTION/); + }); +}); + +test('cli surfaces config-file emit errors with the structured formatter', t => { + withTempDir(dir => { + fs.writeFileSync( + path.join(dir, '.openapi-ng.yaml'), + 'input: x.yaml\nemit: [bogus]\n', + 'utf8', + ); + const result = runCli(['generate'], dir); + t.is(result.status, 1); + t.regex(result.stderr, /Error \[E_INVALID_OPTION\]/); + t.regex(result.stderr, /'bogus'/); + }); +}); + +// ── Lazy-load: native binding not loaded for non-generate commands ──────────── + +test('--help works without loading native binding', t => { + const result = runCli(['--help'], repoRoot, { + env: { ...process.env, OPENAPI_NG_DISABLE_NATIVE_FOR_TEST: '1' }, + }); + t.is(result.status, 0); + t.true(result.stdout.includes('openapi-ng')); + t.is(result.stderr, ''); +}); + +test('--version works without loading native binding', t => { + const result = runCli(['--version'], repoRoot, { + env: { ...process.env, OPENAPI_NG_DISABLE_NATIVE_FOR_TEST: '1' }, + }); + t.is(result.status, 0); + t.regex(result.stdout.trim(), /^\d+\.\d+\.\d+/); +}); + +test('cli --help mentions https URL input', t => { + const result = runCli(['--help']); + // Bare `openapi-ng` (no subcommand) is a usage error, exit 2 — but --help + // is explicit, exit 0. + t.is(result.status, 0); + t.true(result.stdout.includes('https://')); +}); + +test('cli generate --help describes --input accepting path or url', t => { + const result = runCli(['generate', '--help']); + t.is(result.status, 0); + t.true(result.stdout.includes('path|url')); +}); diff --git a/__test__/diagnostic-codes-drift.spec.ts b/__test__/diagnostic-codes-drift.spec.ts new file mode 100644 index 0000000..bb511ba --- /dev/null +++ b/__test__/diagnostic-codes-drift.spec.ts @@ -0,0 +1,82 @@ +import test from 'ava'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; +import ts from 'typescript'; + +const here = dirname(fileURLToPath(import.meta.url)); +const dtsInPath = resolve(here, '..', 'index.d.ts.in'); +const diagnosticsPagePath = resolve( + here, + '..', + 'website', + 'src', + 'content', + 'docs', + 'reference', + 'diagnostics.md', +); + +const dtsIn = readFileSync(dtsInPath, 'utf8'); +const diagnosticsPage = readFileSync(diagnosticsPagePath, 'utf8'); + +// Pull every ```ts ... ``` fenced block out of the Starlight page and +// concatenate them: the page intentionally splits `DiagnosticCode` and +// `DiagnosticSubcode` across separate snippets. Joining lets us pass +// the result to a single TS parse. +function tsBlocksFromMarkdown(md: string): string { + const out: string[] = []; + const fence = /```ts\n([\s\S]*?)\n```/g; + let m: RegExpExecArray | null; + while ((m = fence.exec(md)) !== null) { + out.push(m[1]); + } + return out.join('\n\n'); +} + +// Parse the source as TypeScript and pull every string-literal member +// of the union assigned to `type = ...`. AST-based to ride out +// reformatting (comments, trailing commas in unions, alternative join +// styles) that a regex would silently mis-handle. +function extractUnion(source: string, name: string): Set { + const sf = ts.createSourceFile( + `${name}.ts`, + source, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ts.ScriptKind.TS, + ); + let found: ts.TypeAliasDeclaration | null = null; + sf.forEachChild(node => { + if (ts.isTypeAliasDeclaration(node) && node.name.text === name) { + found = node; + } + }); + if (!found) throw new Error(`${name} type alias not found`); + const aliased = (found as ts.TypeAliasDeclaration).type; + const members: ts.TypeNode[] = ts.isUnionTypeNode(aliased) + ? [...aliased.types] + : [aliased]; + const values = new Set(); + for (const member of members) { + if (ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal)) { + values.add(member.literal.text); + } else { + throw new Error( + `${name} union contains non-string-literal member: ${member.getText(sf)}`, + ); + } + } + return values; +} + +const docSource = tsBlocksFromMarkdown(diagnosticsPage); + +for (const name of ['DiagnosticCode', 'DiagnosticSubcode']) { + test(`${name}: Starlight diagnostics page matches index.d.ts.in`, t => { + const truth = extractUnion(dtsIn, name); + const documented = extractUnion(docSource, name); + t.true(truth.size > 0, `${name} extraction must be non-empty`); + t.deepEqual([...documented].sort(), [...truth].sort()); + }); +} diff --git a/__test__/fetch-input.spec.ts b/__test__/fetch-input.spec.ts new file mode 100644 index 0000000..a37c0a6 --- /dev/null +++ b/__test__/fetch-input.spec.ts @@ -0,0 +1,379 @@ +import test from 'ava'; +import { fetchInput, isUrl, __setDnsLookupForTest } from '../lib/fetch-input.js'; + +// The SSRF guard added in lib/fetch-input.js performs a DNS lookup on +// every non-IP-literal host. Ava runs tests concurrently within a file, +// so a module-level DNS stub installed by one test can race with +// another. Install a permissive default at module load that resolves +// every host to a public address — the existing tests pass `fetchImpl` +// to mock the network and never care about the DNS resolution. Tests +// that need a different stub use `test.serial` (so they run sequentially +// in declaration order, before any concurrent test) and restore the +// default via `t.teardown`. +type DnsLookup = ( + host: string, + options?: { all?: boolean }, +) => Promise>; +const PUBLIC_DNS_STUB: DnsLookup = async () => [{ address: '1.1.1.1', family: 4 }]; +__setDnsLookupForTest(PUBLIC_DNS_STUB); + +function stubDns(impl: DnsLookup) { + __setDnsLookupForTest(impl); +} +function restoreDns() { + __setDnsLookupForTest(PUBLIC_DNS_STUB); +} + +function jsonResponse( + body: string, + status = 200, + headers: Record = {}, +): Response { + return new Response(body, { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function yamlResponse( + body: string, + status = 200, + headers: Record = {}, +): Response { + return new Response(body, { + status, + headers: { 'content-type': 'application/yaml', ...headers }, + }); +} + +function redirectResponse(location: string, status = 302): Response { + return new Response(null, { status, headers: { location } }); +} + +// --- isUrl --- + +test('isUrl returns true for https://, http://, and uppercase variants', t => { + t.true(isUrl('https://example.com/spec.yaml')); + t.true(isUrl('http://example.com/spec.yaml')); + t.true(isUrl('HTTPS://example.com/spec.yaml')); + t.true(isUrl('HTTP://example.com/spec.yaml')); +}); + +test('isUrl returns false for file paths', t => { + t.false(isUrl('./spec.yaml')); + t.false(isUrl('/absolute/path.yaml')); + t.false(isUrl('spec.yaml')); + t.false(isUrl('')); +}); + +// --- happy-path content-type detection --- + +test('fetchInput returns json format hint for application/json', async t => { + const fetchImpl = async () => jsonResponse('{"openapi":"3.0.3"}'); + const out = await fetchInput('https://x/spec', { fetchImpl }); + t.is(out.format, 'json'); + t.is(out.contents, '{"openapi":"3.0.3"}'); +}); + +test('fetchInput returns yaml format hint for application/yaml', async t => { + const fetchImpl = async () => yamlResponse('openapi: 3.0.3\n'); + const out = await fetchInput('https://x/spec', { fetchImpl }); + t.is(out.format, 'yaml'); +}); + +test('fetchInput returns json hint for application/openapi+json (RFC 6839 +json suffix)', async t => { + const fetchImpl = async () => + new Response('{}', { headers: { 'content-type': 'application/openapi+json' } }); + const out = await fetchInput('https://x/spec', { fetchImpl }); + t.is(out.format, 'json'); +}); + +test('fetchInput returns yaml hint for application/vnd.oai.openapi+yaml', async t => { + const fetchImpl = async () => + new Response('openapi: 3.0.3\n', { + headers: { 'content-type': 'application/vnd.oai.openapi+yaml' }, + }); + const out = await fetchInput('https://x/spec', { fetchImpl }); + t.is(out.format, 'yaml'); +}); + +test('fetchInput falls back to URL pathname extension when content-type is missing', async t => { + const fetchImpl = async () => new Response('openapi: 3.0.3\n'); + const out = await fetchInput('https://x/api/openapi.yaml', { fetchImpl }); + t.is(out.format, 'yaml'); +}); + +test('fetchInput returns null format when no content-type and no URL extension', async t => { + const fetchImpl = async () => new Response('openapi: 3.0.3\n'); + const out = await fetchInput('https://x/api/openapi', { fetchImpl }); + t.is(out.format, null); +}); + +test('fetchInput strips charset parameter from content-type', async t => { + const fetchImpl = async () => + new Response('{}', { + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + const out = await fetchInput('https://x/spec', { fetchImpl }); + t.is(out.format, 'json'); +}); + +// --- scheme enforcement --- + +test('fetchInput rejects http:// URLs', async t => { + const err = await t.throwsAsync(async () => { + await fetchInput('http://example.com/spec.yaml', { + fetchImpl: async () => jsonResponse('{}'), + }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('https://')); +}); + +test('fetchInput accepts uppercase HTTPS:// scheme', async t => { + const fetchImpl = async () => jsonResponse('{}'); + const out = await fetchInput('HTTPS://example.com/spec.yaml', { fetchImpl }); + t.is(out.format, 'json'); +}); + +test('fetchInput rejects uppercase HTTP:// scheme', async t => { + const err = await t.throwsAsync(async () => { + await fetchInput('HTTP://example.com/spec.yaml', { + fetchImpl: async () => jsonResponse('{}'), + }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('https://')); +}); + +// --- redirects --- + +test('fetchInput follows a single 302 to a 200', async t => { + const responses = [ + redirectResponse('https://example.com/final.yaml'), + yamlResponse('openapi: 3.0.3\n'), + ]; + const seen: string[] = []; + const fetchImpl = async (url: string) => { + seen.push(url); + return responses.shift()!; + }; + const out = await fetchInput('https://example.com/redirect', { fetchImpl }); + t.is(out.format, 'yaml'); + t.deepEqual(seen, ['https://example.com/redirect', 'https://example.com/final.yaml']); +}); + +test('fetchInput resolves a relative Location against the current URL', async t => { + const responses = [redirectResponse('/spec.yaml'), yamlResponse('openapi: 3.0.3\n')]; + const seen: string[] = []; + const fetchImpl = async (url: string) => { + seen.push(url); + return responses.shift()!; + }; + await fetchInput('https://example.com/redirect/here', { fetchImpl }); + t.is(seen[1], 'https://example.com/spec.yaml'); +}); + +test('fetchInput errors after 5 hops', async t => { + const fetchImpl = async (url: string) => { + const next = url + '/hop'; + return redirectResponse(next); + }; + const err = await t.throwsAsync(async () => { + await fetchInput('https://example.com/0', { fetchImpl }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('Redirect')); +}); + +test('fetchInput rejects https → http downgrade redirects', async t => { + const responses = [redirectResponse('http://example.com/spec.yaml'), yamlResponse('x')]; + const fetchImpl = async () => responses.shift()!; + const err = await t.throwsAsync(async () => { + await fetchInput('https://example.com/redirect', { fetchImpl }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true( + (err as any).message.includes('downgrade') || + (err as any).message.includes('https to http'), + ); +}); + +test('fetchInput does not auto-follow 304/305/306; surfaces them as HTTP errors', async t => { + for (const status of [304, 305, 306]) { + const err = await t.throwsAsync(async () => { + await fetchInput('https://example.com/spec', { + fetchImpl: async () => new Response(null, { status }), + }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.startsWith(`HTTP ${status}`)); + } +}); + +// --- non-2xx --- + +test('fetchInput surfaces 404 with status and statusText', async t => { + const fetchImpl = async () => + new Response('not found', { status: 404, statusText: 'Not Found' }); + const err = await t.throwsAsync(async () => { + await fetchInput('https://example.com/missing', { fetchImpl }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('404')); +}); + +// --- timeout --- + +test('fetchInput respects the timeout option (shared across redirects)', async t => { + function delayed(ms: number, response: Response, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(response), ms); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }); + } + + // First hop returns a redirect after 30 ms; second hop would return + // a body after 30 ms. Total 60 ms, timeout 40 ms — must abort. + const fetchImpl = async (url: string, init?: RequestInit) => { + const signal = init?.signal as AbortSignal; + if (url.endsWith('/0')) { + return delayed(30, redirectResponse('https://example.com/1'), signal); + } + return delayed(30, yamlResponse('openapi: 3.0.3\n'), signal); + }; + + const err = await t.throwsAsync(async () => { + await fetchInput('https://example.com/0', { fetchImpl, timeoutMs: 40 }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('timed out')); +}); + +// --- size cap --- + +test('fetchInput rejects responses with Content-Length above the cap', async t => { + const body = 'x'.repeat(1000); + const fetchImpl = async () => + new Response(body, { + headers: { 'content-type': 'application/json', 'content-length': '1000' }, + }); + const err = await t.throwsAsync(async () => { + await fetchInput('https://example.com/big.json', { fetchImpl, maxBytes: 500 }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('exceeds')); +}); + +test('fetchInput rejects body that exceeds cap during streaming (no Content-Length)', async t => { + function stream(chunks: string[]): Response { + const enc = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(enc.encode(c)); + controller.close(); + }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + } + const fetchImpl = async () => stream(['x'.repeat(300), 'x'.repeat(300)]); + const err = await t.throwsAsync(async () => { + await fetchInput('https://example.com/streamy.json', { fetchImpl, maxBytes: 500 }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('exceeds')); +}); + +// --- SSRF guard --- +// These tests mutate the module-level DNS stub via stubDns; run them +// serially so they don't race each other, and restore the public-IP +// default via t.teardown. + +test.serial( + 'fetchInput rejects literal IPv4 metadata address before any fetch', + async t => { + t.teardown(restoreDns); + let fetched = false; + const fetchImpl = async () => { + fetched = true; + return jsonResponse('{}'); + }; + const err = await t.throwsAsync(async () => { + await fetchInput('https://169.254.169.254/latest/meta-data/', { fetchImpl }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('169.254.169.254')); + t.false(fetched, 'fetchImpl must not be invoked when the host resolves private'); + }, +); + +test.serial('fetchInput rejects a host whose DNS resolves to loopback', async t => { + t.teardown(restoreDns); + stubDns(async () => [{ address: '127.0.0.1', family: 4 }]); + const fetchImpl = async () => jsonResponse('{}'); + const err = await t.throwsAsync(async () => { + await fetchInput('https://localhost/spec.json', { fetchImpl }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('127.0.0.1')); +}); + +test.serial( + 'fetchInput rejects a redirect that lands on a private address on the second hop', + async t => { + t.teardown(restoreDns); + stubDns(async (host: string) => { + if (host === 'public.example.com') return [{ address: '93.184.216.34', family: 4 }]; + if (host === 'internal.example.com') return [{ address: '10.0.0.5', family: 4 }]; + throw new Error(`unexpected DNS lookup for ${host}`); + }); + const responses = [ + redirectResponse('https://internal.example.com/secret'), + jsonResponse('{}'), + ]; + const fetchImpl = async () => responses.shift()!; + const err = await t.throwsAsync(async () => { + await fetchInput('https://public.example.com/redirect', { fetchImpl }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('10.0.0.5')); + }, +); + +test.serial( + 'fetchInput honours OPENAPI_NG_ALLOW_PRIVATE_HOSTS=1 to allow private addresses', + async t => { + process.env.OPENAPI_NG_ALLOW_PRIVATE_HOSTS = '1'; + t.teardown(() => { + delete process.env.OPENAPI_NG_ALLOW_PRIVATE_HOSTS; + }); + const fetchImpl = async () => jsonResponse('{"ok":true}'); + const out = await fetchInput('https://169.254.169.254/spec', { fetchImpl }); + t.is(out.contents, '{"ok":true}'); + }, +); + +test.serial( + 'fetchInput accepts public IPv4 when DNS resolves outside any blocklist', + async t => { + t.teardown(restoreDns); + stubDns(async () => [{ address: '1.1.1.1', family: 4 }]); + const fetchImpl = async () => jsonResponse('{"ok":true}'); + const out = await fetchInput('https://example.com/spec', { fetchImpl }); + t.is(out.contents, '{"ok":true}'); + }, +); + +test.serial('fetchInput rejects IPv6 loopback literal', async t => { + const fetchImpl = async () => jsonResponse('{}'); + const err = await t.throwsAsync(async () => { + await fetchInput('https://[::1]/spec', { fetchImpl }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('::1')); +}); diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts new file mode 100644 index 0000000..cb904e9 --- /dev/null +++ b/__test__/generate.snapshot.spec.ts @@ -0,0 +1,471 @@ +import test from 'ava'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +// Use the wrapper so caught errors are real `GenerateError` instances +// (the failure snapshots pin `warnings`/`path` from the upgraded class +// shape). +import { generate } from '../lib/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +// Pass fixtures as paths relative to CWD (project root) so display_path +// returns a short, machine-independent path in snapshots and banners. +const fixture = (name: string) => path.join('test', 'fixtures', name); +const snapshot = (name: string) => + path.join(repoRoot, '__test__', 'snapshots', 'generate-native', name); + +function readJsonSnapshot(name: string) { + return JSON.parse(fs.readFileSync(snapshot(name), 'utf8')); +} + +// Strip the per-version banner from artifact contents so snapshots +// survive version bumps. Keep in sync with BANNER_RE in +// scripts/regen-snapshots.mjs. +const BANNER_RE = + /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; + +// Paths whose contents are byte-identical across every success fixture. +// Listed by path in each per-fixture snapshot but the file bodies live +// once under __test__/snapshots/generate-native/static-template/ — see +// scripts/regen-snapshots.mjs for the storage layout. +const STATIC_TEMPLATE_PATHS = new Set(['rest.model.ts', 'rest.util.ts']); +const STATIC_TEMPLATE_DIR = path.join( + repoRoot, + '__test__', + 'snapshots', + 'generate-native', + 'static-template', +); + +/** + * Hydrate a path-only success snapshot into the full shape the live + * `generate` result has: each non-static artifact picks up its + * `contents` from `//`; static-template + * artifacts remain path-only because their bodies are pinned in + * static-template.json and asserted by `staticTemplateArtifacts` below. + */ +function hydrateSuccessSnapshot(fixtureName: string) { + const snap = readJsonSnapshot(`${fixtureName}.success.json`); + const siblingsDir = path.join( + repoRoot, + '__test__', + 'snapshots', + 'generate-native', + fixtureName, + ); + const artifacts = snap.artifacts.map((a: any) => { + if (STATIC_TEMPLATE_PATHS.has(a.path)) { + return { path: a.path }; + } + const filePath = path.join(siblingsDir, a.path); + const contents = fs.readFileSync(filePath, 'utf8'); + return { path: a.path, contents }; + }); + return { ...snap, artifacts }; +} + +/** + * Hydrate the static-template snapshot: list of {path, contents} + * loaded from /static-template/. Sorted by path so + * the deep-equal stays insensitive to OS readdir order. + */ +function hydrateStaticTemplate() { + const snap = readJsonSnapshot('static-template.json'); + const artifacts = snap.artifacts + .map((a: any) => ({ + path: a.path, + contents: fs.readFileSync(path.join(STATIC_TEMPLATE_DIR, a.path), 'utf8'), + })) + .sort((a: any, b: any) => a.path.localeCompare(b.path)); + return { artifacts }; +} + +function normalizeSuccessResult(result: any) { + return { + ...result, + artifacts: result.artifacts.map((a: any) => { + if (STATIC_TEMPLATE_PATHS.has(a.path)) { + return { path: a.path }; + } + return { + ...a, + contents: + typeof a.contents === 'string' ? a.contents.replace(BANNER_RE, '') : a.contents, + }; + }), + }; +} + +function staticTemplateArtifacts(result: any) { + return { + artifacts: result.artifacts + .filter((a: any) => STATIC_TEMPLATE_PATHS.has(a.path)) + .map((a: any) => ({ path: a.path, contents: a.contents.replace(BANNER_RE, '') })) + .sort((a: any, b: any) => a.path.localeCompare(b.path)), + }; +} + +async function successResult(name: string, options: Record = {}) { + return normalizeSuccessResult( + await generate({ + inputPath: fixture(name), + emit: ['models', 'angular'], + ...options, + }), + ); +} + +async function failurePayload(name: string, options: Record = {}) { + try { + await generate({ + inputPath: fixture(name), + emit: ['models', 'angular'], + ...options, + }); + throw new Error(`Expected ${name} to fail.`); + } catch (thrown) { + const e = thrown as any; + return { + code: e.code, + message: e.message, + path: e.path ?? null, + warnings: e.warnings ?? [], + }; + } +} + +const successFixtures = [ + 'petstore-minimal.openapi.yaml', + 'petstore-minimal.openapi.json', + 'petstore-rich.openapi.yaml', + 'petstore-rich.openapi.json', + 'oneof-anyof-composition.openapi.yaml', + 'oneof-anyof-composition.openapi.json', + 'allof-composition.openapi.yaml', + 'additional-properties.openapi.yaml', + // additional-properties: false combined with declared `properties` + // emits the named interface unchanged — OpenAPI semantics treat + // `false` as "no extras beyond what's declared", which is the + // default for TypeScript interfaces, so the field is structurally + // a no-op for our emit. (The `true` form remains rejected — see + // failureFixtures below.) + 'additional-properties-false.openapi.yaml', + 'recursive-model.openapi.yaml', + 'single-entry-composition.openapi.yaml', + 'empty-shapes.openapi.yaml', + 'inline-model.openapi.yaml', + 'nullable-optional.openapi.yaml', + // header-param emits a non-fatal warning for the unsupported `header` + // location; the snapshot pins the full warning shape (code/stage/ + // severity/fatal/message/path) so multi-warning regressions surface + // here, not via a separate substring assertion (E12). + 'header-param.openapi.yaml', + // large-enum exercises both branches of the D8 enum-rendering width + // threshold: 50 values forces multi-line, 2 values stays inline. + 'large-enum.openapi.yaml', + // multi-tag-operation pins the (currently silent) policy that secondary + // tags are dropped — operation_grouper.rs uses tags.first() only. If + // someone later wires multi-tag emission, this snapshot surfaces the + // change. + 'multi-tag-operation.openapi.yaml', + // nullable-oneof exercises apply_nullable over composition: a oneOf + // with `nullable: true` (both at top level and as an inline property + // shape). Pins how the nullable wrapper combines with union semantics. + 'nullable-oneof.openapi.yaml', + // security-schemes confirms that components.securitySchemes blocks are + // silently accepted (no error, no warning) — generation proceeds as if + // the block were not present. If we ever wire auth-aware emission this + // snapshot surfaces the change. + 'security-schemes.openapi.yaml', + // circular-allof exercises the E4 recursion-guard happy path: 5 layers + // of allOf composition that bottom out at BaseAuditFields. Catches any + // off-by-one in MAX_NORMALIZE_DEPTH that would falsely fail legit + // deeply-nested specs. + 'circular-allof.openapi.yaml', + // discriminated-union pins the TS surface for `oneOf` + `discriminator:` + // (D11). + 'discriminated-union.openapi.yaml', + // bench-large is a large realistic spec (100+ schemas, 40+ operations). + // Adding it to the snapshot suite catches regressions that only surface + // at scale — sort order changes, buffer sizing issues, etc. + 'bench-large.openapi.yaml', + // reserved-prop-names pins the identifier-escaping policy: reserved + // words like `class` stay unquoted (valid TS property names), while + // digit-first / kebab / dotted / space-containing names get + // single-quoted via safe_property_name. + 'reserved-prop-names.openapi.yaml', + // jsdoc-descriptions pins JSDoc preservation: schema description on + // interface/type alias/enum, per-property description, and + // operation summary+description merged onto the service member. + 'jsdoc-descriptions.openapi.yaml', + // multi-warning triggers two normalize-stage cookie-parameter warnings + // in one operation. Pins the warning order (sessionId before + // trackingId) so a regression that reorders or coalesces the + // pipeline's pre-fatal diagnostics surfaces here, not as a silent + // change. + 'multi-warning.openapi.yaml', + // deprecated-fields exercises OpenAPI `deprecated: true` mapping to + // `@deprecated` JSDoc on the emitted operation, top-level type alias + // (enum), and per-property declaration. Pins the JSDoc emission so a + // regression that drops the tag surfaces here. + 'deprecated-fields.openapi.yaml', + // recursive-oneof closes the cycle-handling coverage matrix. The + // existing fixtures cover cycles via plain `$ref` properties + // (recursive-model) and bounded deep `allOf` chains (circular-allof); + // this one pins cycles routed through `oneOf` composition, where the + // back-edge lands as an unresolved Ref inside a union member. + 'recursive-oneof.openapi.yaml', + // string-formats exercises the format-dropped warning policy: every + // schema-level `format` hint (uuid, email, uri, date, date-time) + // surfaces as an E_UNSUPPORTED_SEMANTIC warning with subcode + // 'format-dropped'. Pins both the warning text/order and the fact that + // generation still proceeds with the base type. + 'string-formats.openapi.yaml', + // discriminator-mapping pins the `discriminator.mapping` honoring policy: + // when a oneOf carries an explicit wire-value mapping, the narrowed + // literal on each member must use the mapping key (e.g. `feline`, + // `canine`) rather than the lowercased schema name. Regressions that + // ignore `mapping` would emit `'cat'`/`'dog'` here. + 'discriminator-mapping.openapi.yaml', + // discriminator-allof exercises the `oneOf` + `allOf`-shaped members + // path: each member composes a shared base (Animal) with a + // variant-specific tail that redeclares the discriminator property. + // The Intersection walk must reach into the inline part to narrow + // `kind` to a single string literal on Cat/Dog. + 'discriminator-allof.openapi.yaml', + // anchor-modest pins the accept-side boundary of the + // mapping-expansion-exceeded guard: a small anchor (Audit) reused 3× + // across composed schemas — the kind of legitimate anchor pattern + // hand-written specs commonly use. The re-serialised byte ratio stays + // well under the 50× cap, so this fixture proves the guard does not + // regress modest anchor use. The reject-side boundary lives in + // `failureFixtures` as `anchor-fanout.openapi.yaml`. + 'anchor-modest.openapi.yaml', + // body-multipart-mixed-fields exercises the comprehensive multipart + // form-body walker: scalar + array-of-scalar + binary + array-of-binary + // + optional scalar. Pins the FormData IIFE shape (`fd.append(...)` + // per field, `String(...)` cast for scalars, raw passthrough for + // binaries, `if (... !== undefined)` guards for optional fields) and + // the request-interface field types (`Blob | File` and `(Blob | File)[]`). + 'body-multipart-mixed-fields.openapi.yaml', + // body-multipart-ref-to-named-object verifies the flattened_body_ref + // import suppression: when a multipart body schema is a top-level $ref + // to a named object (UploadForm), its properties are inlined as + // form-fields and the now-redundant UploadForm import is omitted. + 'body-multipart-ref-to-named-object.openapi.yaml', + // body-urlencoded-scalar-and-array exercises the urlencoded form-body + // walker: scalar + array-of-scalar. Pins the URLSearchParams IIFE + // shape (distinct from FormData) and the absence of binary fields + // (rejected upstream in normalize for urlencoded). + 'body-urlencoded-scalar-and-array.openapi.yaml', + // response-blob-via-pdf verifies the default response-kind classifier + // routes `application/pdf` to `Blob` using the `requestFactory.blob<…>(…)` variant. + 'response-blob-via-pdf.openapi.yaml', + // response-text-via-text-plain verifies the default response-kind + // classifier routes `text/plain` to `string` using the `requestFactory.text<…>(…)` variant. + 'response-text-via-text-plain.openapi.yaml', + // response-problem-json verifies the `*+json` suffix rule: a media + // type like `application/problem+json` classifies as Json (not Blob), + // so the response is emitted as a typed JSON shape via the default + // `requestFactory<…>(…)` (no non-JSON variant). + 'response-problem-json.openapi.yaml', +] as const; + +for (const fixtureName of successFixtures) { + test(`generate preserves full success payload snapshot for ${fixtureName}`, async t => { + t.deepEqual(await successResult(fixtureName), hydrateSuccessSnapshot(fixtureName)); + }); +} + +// rest.model.ts and rest.util.ts are byte-identical across every success +// fixture. Pin them once here; per-fixture snapshots above keep only the +// `path` for these two artifacts. petstore-minimal is the smallest +// fixture that emits them, so it doubles as the baseline. +test('generate emits stable static-template artifacts (rest.model.ts, rest.util.ts)', async t => { + const baseline = await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + emit: ['models', 'angular'], + }); + t.deepEqual(staticTemplateArtifacts(baseline), hydrateStaticTemplate()); +}); + +// Compile gate: write every success-snapshot's artifacts (plus the static +// templates) to a temp project tree and run `tsc --noEmit` over them. +// Catches regressions where snapshots stay textually stable but the emitted +// TS no longer compiles — e.g. an Angular service that references an +// un-imported response type. Lives in this file (next to the snapshot +// loader) so the inputs the gate type-checks are exactly the snapshots +// committed to disk, not a re-generation that might mask drift. +test('snapshot artifacts type-check under tsc --noEmit', t => { + const repoNodeModules = path.join(repoRoot, 'node_modules'); + if (!fs.existsSync(path.join(repoNodeModules, 'typescript', 'bin', 'tsc'))) { + t.fail('node_modules/typescript not installed — run `pnpm install` first'); + return; + } + + // Co-locate the project tree with the existing angular-consumer fixture + // so module resolution traverses up to repo node_modules (same path that + // tsconfig.json's "moduleResolution": "bundler" relies on). Live as a + // sibling of `generated/` rather than inside it: generate.spec.ts's + // reset helper recursively wipes `generated/` and would race with this + // file under AVA's per-file parallelism. + const compileRoot = path.join( + repoRoot, + '__test__', + 'angular-consumer', + '__snapshot_compile__', + ); + fs.rmSync(compileRoot, { recursive: true, force: true }); + fs.mkdirSync(compileRoot, { recursive: true }); + + const staticArtifacts: Array<{ path: string; contents: string }> = + hydrateStaticTemplate().artifacts; + + const includeGlobs: string[] = []; + for (const fixtureName of successFixtures) { + const snap = hydrateSuccessSnapshot(fixtureName); + const fixtureDir = path.join( + compileRoot, + fixtureName.replace(/[^a-zA-Z0-9_-]+/g, '_'), + ); + fs.mkdirSync(fixtureDir, { recursive: true }); + + for (const artifact of snap.artifacts) { + // Per-fixture snapshots elide static-template `contents` (only the + // `path` survives); pull bodies from the shared static-template + // sibling files via `hydrateStaticTemplate`. + const contents = + typeof artifact.contents === 'string' + ? artifact.contents + : staticArtifacts.find(a => a.path === artifact.path)?.contents; + if (contents === undefined) { + t.fail(`Missing contents for ${artifact.path} in ${fixtureName}`); + return; + } + const filePath = path.join(fixtureDir, artifact.path); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, 'utf8'); + } + includeGlobs.push(`${path.basename(fixtureDir)}/**/*.ts`); + } + + const compileTsconfig = { + extends: '../tsconfig.json', + include: includeGlobs, + }; + fs.writeFileSync( + path.join(compileRoot, 'tsconfig.json'), + JSON.stringify(compileTsconfig, null, 2), + 'utf8', + ); + + execFileSync( + process.execPath, + [path.join(repoNodeModules, 'typescript', 'bin', 'tsc'), '-p', compileRoot], + { + cwd: compileRoot, + stdio: 'pipe', + }, + ); + t.pass('tsc --noEmit succeeded on every success-snapshot artifact set'); +}); + +const failureFixtures = [ + 'unsupported-semantic.openapi.yaml', + 'unsupported-root.yaml', + 'empty-parameter.openapi.yaml', + 'inline-parameter.openapi.yaml', + 'invalid-enum-type.openapi.yaml', + 'invalid-enum-value.openapi.json', + // additional-properties: true (boolean form, not a schema object) is + // explicitly rejected at normalize/schema.rs. Snapshot pins the + // unsupported-subset diagnostic shape. + 'additional-properties-boolean.openapi.yaml', + // External $ref like 'shared.yaml#/...' is rejected at + // normalize/schema.rs's normalize_reference. Snapshot pins the + // unsupported-reference message format. + 'external-ref.openapi.yaml', + // field-collision pins the field-collision policy under smart-flatten: + // an inline-object body whose property name duplicates a path/query + // param is rejected, because hoisting the property to top-level would + // produce a duplicate field on the request interface. The author's + // escape hatch is to either rename the property or hoist the body to a + // named $ref (which nests under `body` instead of flattening). + 'field-collision.openapi.yaml', + // deep-nested-allof exercises the MAX_NORMALIZE_DEPTH guard at 40 + // levels of inline allOf nesting. Pins the unsupported-semantic + // diagnostic so a regression that removes the guard or shifts the + // error code surfaces here, not just in the targeted spec test. + 'deep-nested-allof.openapi.yaml', + // unbalanced-path-template exercises the normalize-stage path string + // validation: paths with unmatched `{` / `}` would otherwise reach + // the emit stage and produce a broken TypeScript template + // (`url: `/pets/id`` with no `${encodeURIComponent(id)}` expansion). + 'unbalanced-path-template.openapi.yaml', + // discriminator-mapping-external-ref pins the normalize-stage + // rejection of a mapping value whose ref shape is not an internal + // `#/components/schemas/` ref. Without routing the mapping resolution + // through `normalize_reference`, an external URL like + // `http://example.com/schemas/Cat` would silently pass as a bare + // literal that never matches any union member. + 'discriminator-mapping-external-ref.openapi.yaml', + // anchor-fanout pins the reject-side boundary of the + // mapping-expansion-exceeded guard: a ~45 KB source whose 500 schemas + // × 16 anchor aliases each re-serialise into ~15 MB of inlined node + // tree (~340× ratio). The guard rejects the input at decode time + // before any normalize/emit work runs. Without this guard, the + // expanded tree would be carried through the entire pipeline and + // surface as a slowdown or memory blow-up rather than a clean + // diagnostic. + 'anchor-fanout.openapi.yaml', + // Policy-violation subcodes introduced in Phase 4 for multipart / + // urlencoded form bodies and unsupported content types. One fixture per + // subcode; each pins the diagnostic so a regression that renames a + // subcode or routes a reject path through a different arm surfaces here. + 'body-multi-content.openapi.yaml', + 'body-content-type-xml.openapi.yaml', + 'body-multipart-nested-object.openapi.yaml', + 'body-multipart-composed-field.openapi.yaml', + 'body-multipart-non-object.openapi.yaml', + 'body-multipart-open-schema.openapi.yaml', + 'body-urlencoded-binary-field.openapi.yaml', + 'body-urlencoded-nested-object.openapi.yaml', +] as const; + +for (const fixtureName of failureFixtures) { + test(`generate preserves full failure payload snapshot for ${fixtureName}`, async t => { + t.deepEqual( + await failurePayload(fixtureName), + readJsonSnapshot(`${fixtureName}.failure.json`), + ); + }); +} + +// malformed.yaml: pin code/path but use a regex on the message so +// upstream serde_yml line/column wording changes don't churn the snapshot. +test('generate preserves stable failure shape for malformed.yaml (regex message)', async t => { + const payload = await failurePayload('malformed.yaml'); + t.is(payload.code, 'E_INPUT_INVALID'); + t.regex(payload.message, /^Failed to decode OpenAPI input as YAML:/); + t.true((payload.path ?? '').endsWith('test/fixtures/malformed.yaml')); + // No pre-fatal warnings expected for a decode-stage failure. + t.deepEqual(payload.warnings, []); +}); + +test('generate preserves full failure payload snapshot for invalid mapped type option', async t => { + t.deepEqual( + await failurePayload('petstore-rich.openapi.yaml', { + mappedTypes: [ + { + schema: 'MissingSchema', + import: '@demo/x', + type: 'Missing', + }, + ], + }), + readJsonSnapshot('petstore-rich.openapi.yaml.invalid-mapped-type.failure.json'), + ); +}); diff --git a/__test__/generate.spec.ts b/__test__/generate.spec.ts new file mode 100644 index 0000000..8ccc2ba --- /dev/null +++ b/__test__/generate.spec.ts @@ -0,0 +1,1558 @@ +import test, { type ExecutionContext } from 'ava'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +// Import from the package root (resolves to lib/index.js via package.json +// `main`), not the native binding directly — the wrapper is what upgrades +// thrown plain errors into `GenerateError` class instances. +import { generate, GenerateError } from '../lib/index.js'; +import { __setFetchImplForTest } from '../lib/fetch-input.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Pass fixtures as paths relative to CWD (project root) so display_path +// returns a short, machine-independent path in banners and diagnostics. +const fixture = (name: string) => path.join('test', 'fixtures', name); + +// ── angular-consumer/generated/ convention ────────────────────────────────── +// +// `__test__/angular-consumer/generated/` is the SHARED working directory +// for any test that needs to emit a real generator output and run `tsc` +// over it against the consumer's tsconfig (the per-purpose +// `tsconfig.*.json` files in that directory each `include` a subset of +// this generated tree). The directory is shared — not per-test — so +// the tsconfigs can stay declarative (each one names a stable +// directory; tests don't have to thread a temp path into a generated +// tsconfig file). +// +// Two contributor-facing rules follow from that: +// +// 1. Tests that emit into `generated/` MUST call +// `resetAngularConsumerGeneratedDir()` before generating, or +// leftover files from a previous test will be compiled too and +// surface as a confusing tsc diagnostic. Ava runs each test file +// serially by default but the order between tests in the same +// file is implementation-defined — never assume a clean state. +// +// 2. The snapshot-suite tsc gate +// (`__test__/generate.snapshot.spec.ts`, "snapshot artifacts +// type-check under tsc --noEmit") writes into a SIBLING subtree +// `__test__/angular-consumer/__snapshot_compile__/` — outside the +// shared `generated/` tree — and cleans it on every run. Living +// next to `generated/` rather than inside it is deliberate: this +// reset helper recursively wipes `generated/`, and under AVA's +// per-file parallelism the two files run concurrently. A future +// test that needs its own preserved tree across runs should +// likewise pick a NEW sibling directory next to `generated/` (e.g. +// `__test__/angular-consumer/generated-/`) rather than +// stash files inside the shared `generated/` tree — the reset +// helper wipes the entire shared directory unconditionally and +// collisions there are silent and hard to debug. The matching +// tsconfig should live next to the existing +// `tsconfig..json` files and `include` only the new +// sibling subtree. +const angularConsumerGeneratedDir = path.join(__dirname, 'angular-consumer', 'generated'); + +function resetAngularConsumerGeneratedDir() { + fs.rmSync(angularConsumerGeneratedDir, { recursive: true, force: true }); + fs.mkdirSync(angularConsumerGeneratedDir, { recursive: true }); +} + +async function withTempDir(run: (dir: string) => void | Promise) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-ng-generate-')); + try { + await run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function stripBanner(contents: string | undefined): string { + return (contents ?? '').replace( + /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u, + '', + ); +} + +// Default emit selection. +const DEFAULT_EMIT = ['models', 'angular'] as const; +const FULL_EMIT = ['models', 'angular'] as const; + +const expectedModelSource = [ + 'export interface Pet {', + ' id: PetId;', + ' name: string;', + ' status: PetStatus;', + ' tags: Tag[];', + ' nickname?: string | null;', + '}', + '', + 'export type PetId = string;', + '', + 'export type PetList = Pet[];', + '', + "export type PetStatus = 'available' | 'pending' | 'sold';", + '', + 'export interface Tag {', + ' id: number;', + ' label: string;', + '}', + '', + 'export interface UpdatePetRequest {', + ' status: PetStatus;', + ' tagIds: number[];', + ' nickname?: string | null;', + '}', + '', +].join('\n'); + +// Paths echoed back by generate() are normalized to forward slash on every +// platform (src/pipeline.rs ~L80 replaces '\\' → '/'), so assert against the +// normalized form rather than path.join, which would produce backslashes on +// Windows. +const unsupportedSemanticDiagnostic = { + code: 'E_UNSUPPORTED_SEMANTIC', + severity: 'error', + message: + 'Unsupported OpenAPI semantic shape: property schema EventEnvelope composition member 2.data must define additionalProperties as a schema object.', + path: 'test/fixtures/unsupported-semantic.openapi.yaml', +}; + +const unsupportedRootDiagnostic = { + code: 'E_INPUT_INVALID', + severity: 'error', + message: + 'Failed to decode OpenAPI input as YAML: components.schemas: invalid type: sequence, expected a map at line 8 column 5', + path: 'test/fixtures/unsupported-root.yaml', +}; + +function artifactPaths(result: { artifacts: Array<{ path: string }> }) { + return result.artifacts.map(artifact => artifact.path); +} + +interface Artifact { + path: string; + contents: string; +} +function getArtifact(result: { artifacts: Artifact[] }, targetPath: string) { + return result.artifacts.find(artifact => artifact.path === targetPath); +} + +function assertRestHelpers(t: ExecutionContext, result: { artifacts: Artifact[] }) { + const restModel = getArtifact(result, 'rest.model.ts'); + const restUtil = getArtifact(result, 'rest.util.ts'); + + t.truthy(restModel); + t.truthy(restUtil); + t.true(restModel?.contents?.includes('export interface CommonRequest')); + t.true(restModel?.contents?.includes('export type HttpResourceOptionsUnion')); + t.true(restUtil?.contents?.includes('export const requestFactory')); + t.true(restUtil?.contents?.includes('export interface ZeroArgRequestFnVoid')); +} + +function assertPetServiceArtifact( + t: ExecutionContext, + serviceSource: string | undefined, +) { + t.truthy(serviceSource); + t.true(serviceSource?.includes('export class PetRest {')); + t.true(serviceSource?.includes('requestFactory.zeroArg(')); + t.true(serviceSource?.includes('requestFactory(')); + t.true(serviceSource?.includes('requestFactory(')); + t.true(serviceSource?.includes('export interface GetPetParams {')); + t.true(serviceSource?.includes('export interface UpdatePetParams {')); + t.true(serviceSource?.includes('petId: PetId;')); + t.true(serviceSource?.includes('includeHistory?: boolean;')); + t.true(serviceSource?.includes('body: UpdatePetRequest;')); + t.true(serviceSource?.includes('body: body,')); +} + +test('normalize rejects path templates with unbalanced { } braces', async t => { + // Before the normalize-stage guard, a path like `/pets/{id` would slip + // through to the emit stage and silently produce a broken TypeScript + // template (`url: `/pets/id`` with no `${encodeURIComponent(id)}` + // expansion). The check rejects the spec instead. + const err = await t.throwsAsync( + async () => { + await generate({ + inputPath: fixture('unbalanced-path-template.openapi.yaml'), + emit: ['models', 'angular'], + }); + }, + { instanceOf: GenerateError }, + ); + t.is(err?.code, 'E_UNSUPPORTED_SEMANTIC'); + t.regex(err?.message ?? '', /unbalanced.*'\{'/u); + t.regex(err?.message ?? '', /\/pets\/\{id/u); +}); + +test('OpenAPI deprecated:true is mapped to @deprecated JSDoc on operations and properties', async t => { + const result = await generate({ + inputPath: fixture('deprecated-fields.openapi.yaml'), + emit: ['models', 'angular'], + }); + + const model = result.artifacts.find(a => a.path === 'model.generated.ts'); + const service = result.artifacts.find(a => a.path === 'rest/pet.rest.generated.ts'); + t.truthy(model); + t.truthy(service); + + // Deprecated enum (top-level type alias) carries @deprecated JSDoc. + t.regex( + model?.contents ?? '', + /\/\*\*[\s\S]*?Adoption state, legacy spelling\.[\s\S]*?@deprecated[\s\S]*?\*\/\s*export type LegacyPetStatus/u, + ); + // Deprecated property inside an interface gets a per-property JSDoc. + t.regex( + model?.contents ?? '', + /\/\*\*[\s\S]*?@deprecated[\s\S]*?\*\/\s*legacyTagId\?: number;/u, + ); + // Non-deprecated property nearby does NOT pick up the marker — its + // preceding line is the closing `*/` of the legacyTagId block, not a + // standalone `@deprecated` JSDoc. + const beforeTagIds = (model?.contents ?? '').match( + /([^\n]*\n[^\n]*\n)\s*tagIds\?: number\[\];/u, + ); + t.truthy(beforeTagIds, 'expected to find tagIds property'); + t.false( + beforeTagIds?.[1]?.includes('@deprecated') ?? false, + 'tagIds must not be preceded by a @deprecated JSDoc', + ); + + // Deprecated operation: @deprecated lives in the JSDoc above the + // service method, preserved alongside the merged summary+description. + t.regex( + service?.contents ?? '', + /\/\*\*[\s\S]*?Retrieve a single pet[\s\S]*?@deprecated[\s\S]*?\*\/\s*readonly getPet/u, + ); +}); + +test('every generated artifact carries the version banner', async t => { + // Forward-compat regression guard: pipeline emits `format!("{banner}{body}")` + // for every artifact (models, rest.model.ts, rest.util.ts, per-tag service). + // If any emit path ever materialises an artifact without threading the banner + // through, this catches it before consumers see a banner-less file. + const result = await generate({ + inputPath: fixture('petstore-rich.openapi.yaml'), + emit: ['models', 'angular'], + }); + + t.true(result.artifacts.length >= 4, 'fixture should emit models + rest.* + service'); + for (const artifact of result.artifacts) { + t.regex( + artifact.contents, + /^\/\/ Generated by openapi-ng v\d/, + `${artifact.path} must start with the version banner`, + ); + } +}); + +test.serial( + 'generate emits deterministic model and angular service artifacts for rich JSON and YAML fixtures', + async t => { + resetAngularConsumerGeneratedDir(); + + await withTempDir(async outputPath => { + const jsonResult = await generate({ + inputPath: fixture('petstore-rich.openapi.json'), + outputPath: angularConsumerGeneratedDir, + emit: ['models', 'angular'], + }); + const yamlResult = await generate({ + inputPath: fixture('petstore-rich.openapi.yaml'), + outputPath, + emit: ['models', 'angular'], + }); + + t.deepEqual(jsonResult.diagnostics, []); + t.deepEqual(yamlResult.diagnostics, []); + + t.deepEqual( + { ...jsonResult.summary, normalizedSourcePath: undefined }, + { ...yamlResult.summary, normalizedSourcePath: undefined }, + ); + + t.like(jsonResult, { + summary: { + specVersion: '3.0.3', + title: 'Petstore Rich', + pathCount: 2, + operationCount: 3, + schemaCount: 6, + }, + artifacts: [ + { path: 'model.generated.ts' }, + { path: 'rest.model.ts' }, + { path: 'rest.util.ts' }, + { path: 'rest/pet.rest.generated.ts' }, + ], + }); + + t.is( + jsonResult.summary.normalizedSourcePath, + 'test/fixtures/petstore-rich.openapi.json', + ); + t.is( + yamlResult.summary.normalizedSourcePath, + 'test/fixtures/petstore-rich.openapi.yaml', + ); + // Generated artifacts now carry a "// Source: " banner line, so + // JSON and YAML versions of the same spec produce intentionally + // different bytes. Compare paths and verify that body (post-banner) + // matches expectedModelSource for both surfaces. + t.deepEqual( + jsonResult.artifacts.map(a => a.path), + yamlResult.artifacts.map(a => a.path), + ); + t.is(stripBanner(jsonResult.artifacts[0]?.contents), expectedModelSource); + t.is(stripBanner(yamlResult.artifacts[0]?.contents), expectedModelSource); + assertRestHelpers(t, jsonResult); + assertPetServiceArtifact( + t, + getArtifact(jsonResult, 'rest/pet.rest.generated.ts')?.contents, + ); + t.true(fs.existsSync(path.join(angularConsumerGeneratedDir, 'model.generated.ts'))); + t.true(fs.existsSync(path.join(angularConsumerGeneratedDir, 'rest.model.ts'))); + t.true(fs.existsSync(path.join(angularConsumerGeneratedDir, 'rest.util.ts'))); + t.true( + fs.existsSync( + path.join(angularConsumerGeneratedDir, 'rest', 'pet.rest.generated.ts'), + ), + ); + t.is( + stripBanner( + fs.readFileSync( + path.join(angularConsumerGeneratedDir, 'model.generated.ts'), + 'utf8', + ), + ), + expectedModelSource, + ); + assertPetServiceArtifact( + t, + fs.readFileSync( + path.join(angularConsumerGeneratedDir, 'rest', 'pet.rest.generated.ts'), + 'utf8', + ), + ); + }); + }, +); + +test('generate defaults emit to models+angular when omitted, matching the CLI', async t => { + // No `emit` field — the JS wrapper must apply the CLI's DEFAULT_EMIT + // (`['models', 'angular']`) so the consumer receives the same artifact + // set as `openapi-ng generate -i `. + const result = await generate({ inputPath: fixture('petstore-minimal.openapi.yaml') }); + + t.true( + result.artifacts.some(a => a.path === 'model.generated.ts'), + 'default emit must include the models artifact', + ); + t.true( + result.artifacts.some(a => a.path.endsWith('.rest.generated.ts')), + 'default emit must include an angular service artifact', + ); +}); + +test('generate produces models+angular when emit lists both targets', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('petstore-rich.openapi.yaml'), + outputPath, + emit: ['models', 'angular'], + }); + + t.deepEqual(result.diagnostics, []); + t.deepEqual(artifactPaths(result), [ + 'model.generated.ts', + 'rest.model.ts', + 'rest.util.ts', + 'rest/pet.rest.generated.ts', + ]); + t.is(stripBanner(result.artifacts[0]?.contents), expectedModelSource); + assertRestHelpers(t, result); + assertPetServiceArtifact( + t, + getArtifact(result, 'rest/pet.rest.generated.ts')?.contents, + ); + }); +}); + +test('generate rejects empty emit array as an invalid option', async t => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + emit: [], + }); + }); + + t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance'); + t.is((error as any)?.code, 'E_INVALID_OPTION'); + t.regex(error?.message ?? '', /emit/i); +}); + +test('generate auto-includes models when emit lists only angular, surfacing a non-fatal warning', async t => { + const result = await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + emit: ['angular'], + }); + + // Models artifact is present even though the caller did not list 'models'. + t.true(result.artifacts.some(a => a.path === 'model.generated.ts')); + t.true(result.artifacts.some(a => a.path.startsWith('rest/'))); + + // The pipeline surfaces the auto-include as an E_INVALID_OPTION warning + // so consumers can see (with --verbose, or by inspecting diagnostics) + // that their emit list was widened. + const warning = result.diagnostics.find(d => d.code === 'E_INVALID_OPTION'); + t.truthy(warning, 'expected an E_INVALID_OPTION warning'); + t.is(warning?.severity, 'warning'); + t.regex(warning?.message ?? '', /Auto-included 'models'/); + t.regex(warning?.message ?? '', /'angular'/); +}); + +test('generate rejects unknown option keys with E_INVALID_OPTION instead of silently ignoring them', async t => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + emit: [...DEFAULT_EMIT], + // typo: lowercase `inputpath` and an unrecognised `artifacts` option + inputpath: 'oops', + artifacts: 'metadata', + } as any); + }); + + t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance'); + t.is((error as any)?.code, 'E_INVALID_OPTION'); + t.regex(error?.message ?? '', /inputpath/); + t.regex(error?.message ?? '', /artifacts/); +}); + +test('generate rejects non-array emit with a typed E_INVALID_OPTION (subcode shape) before crossing the NAPI boundary', async t => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + outputPath: 'unused', + // typo: string instead of EmitTarget[] + emit: 'angular', + } as any); + }); + + t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance'); + t.is((error as any)?.code, 'E_INVALID_OPTION'); + t.is((error as any)?.subcode, 'shape'); + t.regex(error?.message ?? '', /emit must be an array/); +}); + +test('generate rejects non-array mappedTypes with E_INVALID_OPTION (subcode shape) before crossing the NAPI boundary', async t => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + outputPath: 'unused', + emit: [...DEFAULT_EMIT], + // typo: scalar instead of MappedType[] + mappedTypes: 'foo', + } as any); + }); + + t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance'); + t.is((error as any)?.code, 'E_INVALID_OPTION'); + t.is((error as any)?.subcode, 'shape'); + t.regex(error?.message ?? '', /mappedTypes must be an array/); +}); + +test('generate rejects emit entries not in EmitTarget union', async t => { + const err = await t.throwsAsync( + async () => { + await generate({ + inputPath: 'x.yaml', + emit: ['Models'] as any, // capitalized → invalid + }); + }, + { instanceOf: GenerateError }, + ); + t.is(err?.code, 'E_INVALID_OPTION'); + t.is(err?.subcode, 'shape'); + t.regex(err?.message ?? '', /'Models'/); +}); + +test('generate rejects non-string inputPath', async t => { + // Cast to any: TypeScript would catch this, but a JS consumer wouldn't. + const err = await t.throwsAsync( + async () => { + await generate({ inputPath: 123 as any, emit: ['models'] }); + }, + { instanceOf: GenerateError }, + ); + t.is(err?.code, 'E_INVALID_OPTION'); + t.is(err?.subcode, 'shape'); + t.regex(err?.message ?? '', /inputPath/); +}); + +test('generate rejects malformed mappedTypes entries', async t => { + const err = await t.throwsAsync( + async () => { + await generate({ + inputPath: 'x.yaml', + emit: ['models'], + mappedTypes: [{ schema: 'X' } as any], // missing import + type + }); + }, + { instanceOf: GenerateError }, + ); + t.is(err?.code, 'E_INVALID_OPTION'); + t.is(err?.subcode, 'shape'); +}); + +test('generate rejects when neither inputPath nor inputContents is set', async t => { + const err = await t.throwsAsync(async () => { + await generate({ emit: ['models'] } as any); + }); + t.true(err instanceof GenerateError); + t.is((err as any).code, 'E_INVALID_OPTION'); + t.is((err as any).subcode, 'shape'); + t.true((err as any).message.includes('exactly one')); +}); + +test('generate rejects when both inputPath and inputContents are set', async t => { + const err = await t.throwsAsync(async () => { + await generate({ + inputPath: 'spec.yaml', + inputContents: 'openapi: 3.0.3\n', + displayPath: 'inline', + emit: ['models'], + } as any); + }); + t.is((err as any).code, 'E_INVALID_OPTION'); + t.is((err as any).subcode, 'shape'); +}); + +test('generate rejects inputContents without displayPath', async t => { + const err = await t.throwsAsync(async () => { + await generate({ + inputContents: 'openapi: 3.0.3\n', + emit: ['models'], + } as any); + }); + t.is((err as any).code, 'E_INVALID_OPTION'); + t.is((err as any).subcode, 'shape'); + t.true((err as any).message.includes('displayPath')); +}); + +test('generate rejects inputFormat with inputPath', async t => { + const err = await t.throwsAsync(async () => { + await generate({ + inputPath: 'spec.yaml', + inputFormat: 'json', + emit: ['models'], + } as any); + }); + t.is((err as any).code, 'E_INVALID_OPTION'); + t.is((err as any).subcode, 'shape'); +}); + +test('generate accepts inputContents with displayPath and yields a result', async t => { + const yaml = 'openapi: 3.0.3\ninfo: { title: Inline Pkg, version: 1.0.0 }\npaths: {}\n'; + const result = await generate({ + inputContents: yaml, + displayPath: 'https://example.com/spec.yaml', + inputFormat: 'yaml', + emit: ['models'], + } as any); + t.is(result.summary.title, 'Inline Pkg'); + t.is(result.summary.normalizedSourcePath, 'https://example.com/spec.yaml'); +}); + +test('generate fails when discriminator property is absent on a oneOf member', async t => { + const err = await t.throwsAsync( + async () => { + await generate({ + inputPath: fixture('discriminator-missing-property.openapi.yaml'), + emit: ['models'], + }); + }, + { instanceOf: GenerateError }, + ); + t.is(err?.code, 'E_POLICY_VIOLATION'); + t.is(err?.subcode, 'missing-discriminator-property'); + t.regex(err?.message ?? '', /Cat/); +}); + +test('generate rejects empty-string outputPath; in-memory mode requires omitting the field', async t => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + outputPath: '', + emit: [...DEFAULT_EMIT], + }); + }); + + t.truthy(error); + t.is((error as any)?.code, 'E_INVALID_OPTION'); + t.regex(error?.message ?? '', /outputPath must be a non-empty path/); +}); + +test('generate returns artifact contents and writes the same bytes to disk', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('petstore-rich.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + + t.true(result.artifacts.length > 0); + for (const artifact of result.artifacts) { + t.is(typeof artifact.contents, 'string'); + t.true(artifact.contents.length > 0, `${artifact.path} contents must be non-empty`); + } + // Files were still written to disk. + t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); + const modelArtifact = getArtifact(result, 'model.generated.ts'); + t.is( + fs.readFileSync(path.join(outputPath, 'model.generated.ts'), 'utf8'), + modelArtifact?.contents, + ); + }); +}); + +test('generate exposes flattened request interfaces and operation-object APIs in the service artifact', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('petstore-rich.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + + t.deepEqual(result.diagnostics, []); + const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts'); + + t.truthy(serviceArtifact); + assertPetServiceArtifact(t, serviceArtifact?.contents); + t.false(serviceArtifact?.contents?.includes('#http')); + t.false(serviceArtifact?.contents?.includes('HttpClient')); + t.true(serviceArtifact?.contents?.includes('requestFactory.zeroArg(')); + t.true(serviceArtifact?.contents?.includes('requestFactory(')); + t.true(serviceArtifact?.contents?.includes('requestFactory(')); + }); +}); + +test('generate surfaces the bounded option contract through the node api', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('petstore-rich.openapi.yaml'), + outputPath, + emit: [...FULL_EMIT], + mappedTypes: [ + { + schema: 'PetId', + import: '@demo/native-types', + type: 'PetIdentifier', + }, + ], + }); + + t.deepEqual(result.diagnostics, []); + }); +}); + +test.serial( + 'generate emits discriminated-union model that narrows correctly in a consumer project', + async t => { + resetAngularConsumerGeneratedDir(); + + await generate({ + inputPath: fixture('discriminated-union.openapi.yaml'), + outputPath: angularConsumerGeneratedDir, + emit: [...DEFAULT_EMIT], + }); + + execFileSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.discriminator.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + stdio: 'pipe', + }, + ); + t.pass('tsc type-checked discriminated-union narrowing successfully'); + }, +); + +test.serial( + 'generate emits service helper typings that type-check in a consumer project', + async t => { + resetAngularConsumerGeneratedDir(); + + await generate({ + inputPath: fixture('petstore-rich.openapi.json'), + outputPath: angularConsumerGeneratedDir, + emit: [...DEFAULT_EMIT], + }); + + execFileSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.service.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + stdio: 'pipe', + }, + ); + t.pass('tsc type-checked generated service artifact successfully'); + }, +); + +// Type-surface gate for `EmitTarget`. The proof file destructures the +// runtime `EmitTarget` and asserts assignability between its named +// properties and the published `EmitTarget` type, compiled under +// `isolatedModules`. If the published `index.d.ts` ever regresses to a +// `const enum` form, isolatedModules' single-file transpilation rejects +// the import and this gate fails. +test('public EmitTarget surface compiles under isolatedModules', t => { + execFileSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.emit-target.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + stdio: 'pipe', + }, + ); + t.pass('tsc type-checked EmitTarget public surface under isolatedModules'); +}); + +test.serial( + 'generate emits oneOf/anyOf composition models that type-check in a consumer project', + async t => { + resetAngularConsumerGeneratedDir(); + + await generate({ + inputPath: fixture('oneof-anyof-composition.openapi.yaml'), + outputPath: angularConsumerGeneratedDir, + emit: [...DEFAULT_EMIT], + }); + + execFileSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.consumer.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + stdio: 'pipe', + }, + ); + t.pass('tsc type-checked generated oneOf/anyOf composition output successfully'); + }, +); + +// Bulk-spec compile gate: bench-large emits 30 entity models and 30 +// service files referencing them. The all-snapshots compile gate in +// generate.snapshot.spec.ts covers the snapshot bytes; this dedicated +// gate exercises a freshly-generated output against the real Angular +// consumer's tsconfig, so a regression in bulk emit surfaces as a +// targeted failure (not a snapshot diff buried in a 30-file blob). +test.serial( + 'generate emits bulk-spec output that type-checks under an Angular consumer (bench-large)', + async t => { + resetAngularConsumerGeneratedDir(); + + await generate({ + inputPath: fixture('bench-large.openapi.yaml'), + outputPath: angularConsumerGeneratedDir, + emit: [...DEFAULT_EMIT], + }); + + execFileSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.bench-large.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + stdio: 'pipe', + }, + ); + t.pass('tsc type-checked bulk-spec (bench-large) generated output successfully'); + }, +); + +// Negative-compile fixture: asserts that tsc FAILS when assigning a Dog to a +// Cat-typed slot. If this test ever starts passing (tsc exits 0), it means the +// tagged union has collapsed to `any` or similar — a type-soundness regression. +test.serial( + 'negative-proof tsconfig fails to compile (type-soundness regression detector)', + async t => { + resetAngularConsumerGeneratedDir(); + + await generate({ + inputPath: fixture('discriminated-union.openapi.yaml'), + outputPath: angularConsumerGeneratedDir, + emit: [...DEFAULT_EMIT], + }); + + const result = spawnSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.negative.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + encoding: 'utf8', + }, + ); + + const stdout = (result.stdout ?? '') + (result.stderr ?? ''); + + // Must fail — otherwise types have become too permissive (e.g. collapsed to `any`). + t.not(result.status, 0, `expected tsc to fail but it succeeded; output:\n${stdout}`); + // The specific error must be TS2322 (type assignability), not e.g. TS2304 (cannot find name). + t.regex(stdout, /TS2322/, `expected TS2322 type-mismatch error; output:\n${stdout}`); + }, +); + +// Compile gate for Phase 7's request-body (multipart + urlencoded) and +// non-JSON response (Blob / string / ArrayBuffer) surfaces. The proof +// file (src/form-non-json-proof.ts) asserts call-site typing on each +// service and pins the carrier type of `observable` / `resource` via +// expectType<>. Generated from a combined fixture that also exercises +// the user-facing `responseTypeMapping` knob (octet-stream → ArrayBuffer). +// A regression that widens a binary field to `any`, drops the response +// generic, or fails to honour the mapping fails here under tsc --noEmit. +test.serial( + 'generate emits form-body and non-JSON response typings that type-check in a consumer project', + async t => { + resetAngularConsumerGeneratedDir(); + + await generate({ + inputPath: fixture('consumer-forms-and-non-json.openapi.yaml'), + outputPath: angularConsumerGeneratedDir, + emit: [...DEFAULT_EMIT], + responseTypeMapping: [ + { contentType: 'application/octet-stream', responseType: 'arrayBuffer' }, + ], + }); + + execFileSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.form-non-json.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + stdio: 'pipe', + }, + ); + t.pass('tsc type-checked form-body + non-JSON response artifacts successfully'); + }, +); + +// Negative-compile fixture for the multipart binary-field surface: +// asserts that tsc FAILS when assigning a plain `string` to a +// `Blob | File` slot in the generated `UpdatePetAvatarParams`. If this +// test starts passing (tsc exits 0), the binary field has widened to +// `any`/`unknown` or to `string`-permissive — a type-soundness +// regression that would silently let callers ship non-binary payloads +// down the multipart path. +test.serial( + 'negative-proof tsconfig fails to compile when a string is passed to a binary multipart field', + async t => { + resetAngularConsumerGeneratedDir(); + + await generate({ + inputPath: fixture('consumer-forms-and-non-json.openapi.yaml'), + outputPath: angularConsumerGeneratedDir, + emit: [...DEFAULT_EMIT], + responseTypeMapping: [ + { contentType: 'application/octet-stream', responseType: 'arrayBuffer' }, + ], + }); + + const result = spawnSync( + process.execPath, + [ + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsc'), + '-p', + path.join(__dirname, 'angular-consumer', 'tsconfig.negative-binary-field.json'), + ], + { + cwd: path.join(__dirname, 'angular-consumer'), + encoding: 'utf8', + }, + ); + + const stdout = (result.stdout ?? '') + (result.stderr ?? ''); + + // Must fail — otherwise the binary field has widened (e.g. to `string`/`any`). + t.not(result.status, 0, `expected tsc to fail but it succeeded; output:\n${stdout}`); + // The specific error must be TS2322 (type assignability), not e.g. TS2304 (cannot find name). + t.regex(stdout, /TS2322/, `expected TS2322 type-mismatch error; output:\n${stdout}`); + }, +); + +test('generate maps a targeted schema to an imported external type without changing unrelated model symbols', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('petstore-rich.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + + mappedTypes: [ + { + schema: 'PetId', + import: '@demo/native-types', + type: 'PetIdentifier', + }, + ], + }); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + + t.truthy(modelArtifact); + const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts'); + + t.truthy(serviceArtifact); + t.true( + modelArtifact?.contents?.includes( + "import type { PetIdentifier } from '@demo/native-types';", + ), + ); + t.false(modelArtifact?.contents?.includes('export type PetId = string;')); + t.true(modelArtifact?.contents?.includes('export type PetStatus =')); + t.true( + serviceArtifact?.contents?.includes( + "import type { Pet, PetId, PetList, UpdatePetRequest } from '../model.generated';", + ), + ); + t.true(serviceArtifact?.contents?.includes(' petId: PetId;')); + }); +}); + +test('generate encodes oneOf/anyOf composition as focused public contract fragments', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('oneof-anyof-composition.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + + // `format-dropped` warnings are orthogonal to this test's focus on + // composition emit; the snapshot suite pins the full warning list. + t.deepEqual( + result.diagnostics.filter(d => d.subcode !== 'format-dropped'), + [], + ); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + const serviceArtifact = getArtifact( + result, + 'rest/adoption-request.rest.generated.ts', + ); + + t.truthy(modelArtifact); + t.truthy(serviceArtifact); + t.deepEqual(artifactPaths(result), [ + 'model.generated.ts', + 'rest.model.ts', + 'rest.util.ts', + 'rest/adoption-request.rest.generated.ts', + 'rest/pet.rest.generated.ts', + ]); + t.true(modelArtifact?.contents?.includes('export type PetUnion = Cat | Dog;')); + t.true(modelArtifact?.contents?.includes('export type PetUnionList = PetUnion[];')); + t.true(modelArtifact?.contents?.includes(' preferredPet?: PetUnion;')); + t.true(modelArtifact?.contents?.includes(' contact: ContactEmail | ContactPhone;')); + t.true(modelArtifact?.contents?.includes(' notes?: string;')); + t.true(modelArtifact?.contents?.includes(' referralCode?: string | null;')); + t.true(modelArtifact?.contents?.includes(' matchedPet?: PetUnion;')); + t.true(modelArtifact?.contents?.includes(' reviewerNote?: string | null;')); + t.true( + serviceArtifact?.contents?.includes( + 'requestFactory(', + ), + ); + }); +}); + +test('generate keeps mapped-type assertions explicit alongside composition contract coverage', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('oneof-anyof-composition.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + + mappedTypes: [ + { + schema: 'Cat', + import: '@demo/composition-types', + type: 'ExternalCat', + }, + ], + }); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + + t.truthy(modelArtifact); + t.true( + modelArtifact?.contents?.includes( + "import type { ExternalCat } from '@demo/composition-types';", + ), + ); + t.true(modelArtifact?.contents?.includes('export type PetUnion = Cat | Dog;')); + t.true(modelArtifact?.contents?.includes('export type Cat = ExternalCat;')); + t.false(modelArtifact?.contents?.includes(' lives: number;')); + }); +}); + +test('generate emits a re-export for mapped types whose binding name equals the schema name', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('oneof-anyof-composition.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + mappedTypes: [ + // alias=Cat matches the schema, so `import type { ExternalCat + // as Cat }` + `export type Cat = Cat;` would collide. The + // emitter must collapse to a single `export type { ... } from + // '...'` re-export. + { + schema: 'Cat', + import: '@demo/composition-types', + type: 'ExternalCat', + alias: 'Cat', + }, + ], + }); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + t.truthy(modelArtifact); + t.true( + modelArtifact?.contents?.includes( + "export type { ExternalCat as Cat } from '@demo/composition-types';", + ), + ); + t.false( + modelArtifact?.contents?.includes('export type Cat = Cat;'), + 'self-alias case must not emit the broken placeholder', + ); + t.false( + modelArtifact?.contents?.includes('import type { ExternalCat as Cat }'), + 'self-alias case must not emit a separate import-type line', + ); + }); +}); + +test('generate emits a bare re-export when schema name equals imported type name', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('oneof-anyof-composition.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + mappedTypes: [ + { + schema: 'Cat', + import: '@demo/composition-types', + type: 'Cat', + }, + ], + }); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + t.truthy(modelArtifact); + t.true( + modelArtifact?.contents?.includes( + "export type { Cat } from '@demo/composition-types';", + ), + ); + t.false( + modelArtifact?.contents?.includes(' as Cat'), + 'no rename needed when names match', + ); + }); +}); + +test('generate encodes allOf composition as an intersection contract with nullable-vs-optional fragments', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('allof-composition.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + + // `format-dropped` warnings are orthogonal to this test's focus on + // intersection emit; the snapshot suite pins the full warning list. + t.deepEqual( + result.diagnostics.filter(d => d.subcode !== 'format-dropped'), + [], + ); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + const serviceArtifact = getArtifact(result, 'rest/adopter.rest.generated.ts'); + + t.truthy(modelArtifact); + t.truthy(serviceArtifact); + t.deepEqual(artifactPaths(result), [ + 'model.generated.ts', + 'rest.model.ts', + 'rest.util.ts', + 'rest/adopter.rest.generated.ts', + ]); + t.true( + modelArtifact?.contents?.includes( + 'export type AdopterProfile = AuditFields & ContactFields & {', + ), + ); + t.true(modelArtifact?.contents?.includes(' id: string;')); + t.true(modelArtifact?.contents?.includes(' name: string;')); + t.true(modelArtifact?.contents?.includes(' nickname?: string | null;')); + t.true(modelArtifact?.contents?.includes(' archivedAt?: string | null;')); + t.true(modelArtifact?.contents?.includes(' phone?: string;')); + t.true( + serviceArtifact?.contents?.includes( + 'requestFactory(', + ), + ); + }); +}); + +test('generate collapses single-entry oneOf/anyOf/allOf wrappers instead of emitting redundant composition syntax', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('single-entry-composition.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + + t.deepEqual(result.diagnostics, []); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + + t.truthy(modelArtifact); + t.true(modelArtifact?.contents?.includes('export type AnimalView = AnimalBase;')); + t.true(modelArtifact?.contents?.includes('export type AnimalDraft = AnimalBase;')); + t.true(modelArtifact?.contents?.includes('export type ContactPreference = string;')); + t.false(modelArtifact?.contents?.includes('AnimalBase |')); + t.false(modelArtifact?.contents?.includes('AnimalBase &')); + t.false(modelArtifact?.contents?.includes('ContactPreference |')); + t.true(modelArtifact?.contents?.includes(' nickname?: string | null;')); + }); +}); + +test('generate emits Record-based contracts for typed additionalProperties object maps', async t => { + await withTempDir(async outputPath => { + const result = await generate({ + inputPath: fixture('additional-properties.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + + t.deepEqual(result.diagnostics, []); + + const modelArtifact = getArtifact(result, 'model.generated.ts'); + const serviceArtifact = getArtifact(result, 'rest/pet.rest.generated.ts'); + + t.truthy(modelArtifact); + t.truthy(serviceArtifact); + t.true(modelArtifact?.contents?.includes(' petsByBreed: Record;')); + t.true( + modelArtifact?.contents?.includes( + 'export type PetMetadataByTag = Record;', + ), + ); + t.true(serviceArtifact?.contents?.includes('requestFactory.zeroArg(')); + }); +}); + +test('unsupported semantic fixture reports normalize-stage additionalProperties boundary instead of composition rejection', async t => { + await withTempDir(async outputPath => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('unsupported-semantic.openapi.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + }); + + t.truthy(error); + t.true(error instanceof GenerateError); + t.is((error as any)?.code, unsupportedSemanticDiagnostic.code); + t.is((error as any)?.path, unsupportedSemanticDiagnostic.path); + t.regex(error?.message ?? '', /additionalProperties/i); + t.notRegex(error?.message ?? '', /\ballOf\b/i); + // Warnings live on `error.warnings`; the fatal sits at top level. + t.deepEqual((error as any)?.warnings, []); + }); +}); + +test('generate rejects malformed fixture with stable fatal input diagnostic metadata', async t => { + await withTempDir(async outputPath => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('malformed.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + }); + + t.truthy(error); + t.true(error instanceof GenerateError); + t.is((error as any)?.code, 'E_INPUT_INVALID'); + t.true((error as any)?.path.endsWith('test/fixtures/malformed.yaml')); + }); +}); + +test('generate rejects unsupported-root fixture with stable input-stage shape diagnostics', async t => { + await withTempDir(async outputPath => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('unsupported-root.yaml'), + outputPath, + emit: [...DEFAULT_EMIT], + }); + }); + + t.truthy(error); + t.true(error instanceof GenerateError); + t.is((error as any)?.code, unsupportedRootDiagnostic.code); + t.is(error?.message, unsupportedRootDiagnostic.message); + t.is((error as any)?.path, unsupportedRootDiagnostic.path); + }); +}); + +test('generate produces byte-identical output across repeated calls (determinism)', async t => { + // README guarantees deterministic output: "same input always produces + // identical output". Run each fixture three times and assert byte-level + // equality on the full result (summary + diagnostics + artifact contents + // including banner). + const fixtures = ['petstore-rich.openapi.yaml', 'bench-large.openapi.yaml']; + for (const name of fixtures) { + const first = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); + const second = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); + const third = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] }); + t.deepEqual(first, second, `${name}: run 1 vs run 2 must be byte-identical`); + t.deepEqual(second, third, `${name}: run 2 vs run 3 must be byte-identical`); + } +}); + +test('generate rejects schemas nested deeper than MAX_NORMALIZE_DEPTH', async t => { + // Pathological inline-allOf nesting (40 levels) trips the depth guard + // at level 32 — well below the serde recursion limit (~60), so this + // exercises the normalize-side guard rather than the YAML/JSON + // decoder's own protection. The error must surface as a typed + // GenerateError carrying E_UNSUPPORTED_SEMANTIC (no new subcode), with + // a message mentioning "nesting" and "exceeds" so consumers can + // recognise the bound rather than treating it as a generic schema + // shape failure. + const err = await t.throwsAsync( + async () => { + await generate({ + inputPath: path.join( + __dirname, + '../test/fixtures/deep-nested-allof.openapi.yaml', + ), + emit: ['models'], + }); + }, + { instanceOf: GenerateError }, + ); + t.is(err?.code, 'E_UNSUPPORTED_SEMANTIC'); + t.regex(err?.message ?? '', /nesting.*exceeds/i); +}); + +test('generate wraps Rust panic into E_UNEXPECTED GenerateError', async t => { + // Triggered by the cfg-test-only `inputPath: "__panic_for_test__"` sentinel. + const err = await t.throwsAsync( + async () => { + await generate({ + inputPath: '__panic_for_test__', + emit: ['models'], + }); + }, + { instanceOf: GenerateError }, + ); + t.is(err?.code, 'E_UNEXPECTED'); + t.regex(err?.message ?? '', /unexpected.*panic/i); +}); + +test('GenerateError is a real class so consumers can guard with instanceof', async t => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + // emit is required — the missing field surfaces as a fatal + // option-resolution error. + emit: [], + }); + }); + + t.true(error instanceof GenerateError, 'thrown value must be a GenerateError instance'); + t.is((error as any).code, 'E_INVALID_OPTION'); + t.is(typeof (error as any).message, 'string'); + t.true(Array.isArray((error as any).warnings)); +}); + +test('GenerateError carries pre-fatal warnings on error.warnings', async t => { + // The warning-then-fatal fixture trips a cookie-parameter warning on + // operation /a (normalize keeps going past warnings), then a fatal on + // operation /b (unsupported parameter location). Asserts the NAPI + // boundary enriches the thrown error with both surfaces: the fatal at + // top-level and the pre-fatal warning on `warnings[]`. + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('warning-then-fatal.openapi.yaml'), + emit: ['models', 'angular'], + }); + }); + + t.true(error instanceof GenerateError); + t.is((error as any).code, 'E_UNSUPPORTED_SEMANTIC'); + t.regex(error?.message ?? '', /bogus_location/); + + const warnings = (error as any).warnings as Array<{ + code: string; + severity: string; + message: string; + path: string; + }>; + t.true(Array.isArray(warnings)); + t.is(warnings.length, 1, 'cookie param should produce exactly one pre-fatal warning'); + t.is(warnings[0].severity, 'warning'); + t.is(warnings[0].code, 'E_UNSUPPORTED_SEMANTIC'); + t.regex(warnings[0].message, /cookie/); + t.regex(warnings[0].message, /sessionId/); +}); + +test('GenerateError.isGenerateError detects upgraded errors via the cross-realm marker', async t => { + const error = await t.throwsAsync(async () => { + await generate({ + inputPath: fixture('petstore-minimal.openapi.yaml'), + emit: [], + }); + }); + t.true(GenerateError.isGenerateError(error)); + t.false(GenerateError.isGenerateError(new Error('plain'))); + t.false(GenerateError.isGenerateError(null)); + t.false(GenerateError.isGenerateError(undefined)); + t.false(GenerateError.isGenerateError({ code: 'E_INVALID_OPTION' })); +}); + +test('GenerateError marker constant matches the value Rust embeds via build.rs', async t => { + // `lib/error-marker.json` is the single source of truth shared with + // the Rust binding through `env!("OPENAPI_NG_ERROR_MARKER")` (see + // build.rs). The constant inside the published JSON file must remain + // a non-empty string so the cross-realm marker survives the boundary. + const marker = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'lib', 'error-marker.json'), 'utf8'), + ); + t.is(typeof marker.marker, 'string'); + t.true(marker.marker.length > 0); + + // The marker property is non-enumerable on a GenerateError instance — + // expose it only via OwnPropertyDescriptors so that JSON.stringify(err) + // doesn't leak the sentinel into application output. + const error = (await t.throwsAsync(async () => { + await generate({ inputPath: fixture('petstore-minimal.openapi.yaml'), emit: [] }); + }))!; + const descriptor = Object.getOwnPropertyDescriptor(error, marker.marker); + t.truthy(descriptor, 'marker property must be present on the GenerateError instance'); + t.is(descriptor?.enumerable, false); + t.is(descriptor?.value, true); +}); + +test('generate is the public API name (replaces generateNative)', t => { + const mod = createRequire(import.meta.url)('../lib/index.js'); + t.is(typeof mod.generate, 'function'); + t.is(mod.generateNative, undefined); +}); + +test('index.d.ts exposes the generate contract (emit array, contents-only artifacts, GenerateError class)', t => { + const contract = fs.readFileSync(path.join(__dirname, '..', 'index.d.ts'), 'utf8'); + + t.true( + contract.includes( + 'export declare function generate(options: GenerateOptions): Promise', + ), + ); + t.true(contract.includes('export interface GenerateOptions {')); + t.true(contract.includes('inputPath?: string')); + t.true(contract.includes('inputContents?: string')); + t.true(contract.includes('displayPath?: string')); + t.true(contract.includes('inputFormat?: InputFormat')); + t.true(contract.includes('outputPath?: string')); + t.true(contract.includes('emit?: Array')); + t.true(contract.includes('mappedTypes?: Array')); + t.true(contract.includes('export interface GenerateResult {')); + t.true(contract.includes('summary: GenerateSummary')); + t.true(contract.includes('diagnostics: Array')); + t.true(contract.includes('artifacts: Array')); + t.true(contract.includes('export interface GeneratedArtifact {')); + t.true(contract.includes('normalizedSourcePath: string')); + t.true(contract.includes('export declare class GenerateError extends Error {')); + t.true(contract.includes('export interface MappedType {')); + t.true(contract.includes(' schema: string')); + t.true(contract.includes(' import: string')); + t.true(contract.includes(' type: string')); + t.true(contract.includes(' alias?: string')); +}); + +test('naming.methodName strips verb prefix end-to-end (posts_listAll → listAll)', async t => { + const result = await generate({ + inputPath: fixture('verb-prefix.openapi.yaml'), + emit: ['models', 'angular'], + naming: { + methodName: { + from: '{operationId}', + parse: /^[^_]+_(?.+)$/, + format: '{capture.rest}', + case: 'camel', + }, + }, + }); + const service = result.artifacts.find( + (a: { path: string }) => a.path === 'rest/post.rest.generated.ts', + ); + t.assert(service, 'expected post service artifact'); + t.regex(service!.contents, /\blistAll\b/); + t.regex(service!.contents, /\bcreate\b/); + t.notRegex(service!.contents, /\bposts_listAll\b/); + t.notRegex(service!.contents, /\bposts_create\b/); +}); + +test.serial('generate fetches an https URL and uses it as displayPath', async t => { + const yaml = 'openapi: 3.0.3\ninfo: { title: Fetched, version: 1.0.0 }\npaths: {}\n'; + __setFetchImplForTest( + async () => new Response(yaml, { headers: { 'content-type': 'application/yaml' } }), + ); + try { + const result = await generate({ + inputPath: 'https://example.com/spec.yaml', + emit: ['models'], + }); + t.is(result.summary.title, 'Fetched'); + t.is(result.summary.normalizedSourcePath, 'https://example.com/spec.yaml'); + } finally { + __setFetchImplForTest(null); + } +}); + +test.serial('generate rejects http URLs with E_INPUT_INVALID', async t => { + __setFetchImplForTest(async () => new Response('{}')); + try { + const err = await t.throwsAsync(async () => { + await generate({ + inputPath: 'http://example.com/spec.json', + emit: ['models'], + }); + }); + t.is((err as any).code, 'E_INPUT_INVALID'); + t.true((err as any).message.includes('https://')); + } finally { + __setFetchImplForTest(null); + } +}); + +test.serial( + 'generate rejects both inputContents and a URL inputPath without fetching', + async t => { + let fetched = false; + __setFetchImplForTest(async () => { + fetched = true; + return new Response('openapi: 3.0.3\n', { + headers: { 'content-type': 'application/yaml' }, + }); + }); + try { + const err = await t.throwsAsync(async () => { + await generate({ + inputPath: 'https://example.com/spec.yaml', + inputContents: 'openapi: 3.0.3\n', + displayPath: 'inline', + emit: ['models'], + } as any); + }); + t.is((err as any).code, 'E_INVALID_OPTION'); + t.is((err as any).subcode, 'shape'); + t.false( + fetched, + 'fetchInput must not be called when validation would reject the call anyway', + ); + } finally { + __setFetchImplForTest(null); + } + }, +); + +// Three concurrent generate() calls against distinct temp dirs, each +// pointed at the same on-disk spec. Catches mutable-state regressions in +// `prepareOptions` (the options object is no longer mutated; see the +// related non-mutation fix in lib/wrapper-core.js) and any future caching +// layer that might leak across simultaneous invocations. +test('generate is safe to run concurrently across distinct outputs', async t => { + const baseOptions = { + inputPath: fixture('petstore-minimal.openapi.yaml'), + emit: [...DEFAULT_EMIT] as ('models' | 'angular')[], + naming: { methodName: '{operationId}' }, + }; + + await withTempDir(async dirA => { + await withTempDir(async dirB => { + await withTempDir(async dirC => { + const sharedNaming = baseOptions.naming; + const [a, b, c] = await Promise.all([ + generate({ ...baseOptions, outputPath: dirA }), + generate({ ...baseOptions, outputPath: dirB }), + generate({ ...baseOptions, outputPath: dirC }), + ]); + + // The shared `naming` object must be untouched after concurrent + // calls — `prepareOptions` builds a normalized naming via spread + // instead of writing back to the caller's input. + t.is(baseOptions.naming, sharedNaming); + t.deepEqual(baseOptions.naming, { methodName: '{operationId}' }); + + // Every call returns the same artifact set (deterministic) and + // every output directory contains the same file list. + const namesA = a.artifacts.map(art => art.path).sort(); + const namesB = b.artifacts.map(art => art.path).sort(); + const namesC = c.artifacts.map(art => art.path).sort(); + t.deepEqual(namesA, namesB); + t.deepEqual(namesA, namesC); + t.true(namesA.includes('model.generated.ts')); + + for (const dir of [dirA, dirB, dirC]) { + t.true( + fs.existsSync(path.join(dir, 'model.generated.ts')), + `model.generated.ts must exist in ${dir}`, + ); + } + + // Contents are byte-identical across all three calls. + const aByPath = new Map(a.artifacts.map(art => [art.path, art.contents])); + for (const art of b.artifacts) { + t.is(aByPath.get(art.path), art.contents, `mismatch at ${art.path}`); + } + }); + }); + }); +}); diff --git a/__test__/package.json b/__test__/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/__test__/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/__test__/package.spec.ts b/__test__/package.spec.ts new file mode 100644 index 0000000..ada59d4 --- /dev/null +++ b/__test__/package.spec.ts @@ -0,0 +1,229 @@ +import test from 'ava'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { GenerateError } from '../lib/index.js'; +import * as lib from '../lib/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const require = createRequire(import.meta.url); + +test('browser entry exists and fails with an explicit unsupported-runtime error', async t => { + const browserEntry = require(path.join(repoRoot, 'browser.js')) as { + generate: (options?: unknown) => Promise; + }; + + t.is(typeof browserEntry.generate, 'function'); + + // The browser entry is a hard-error stub — browser/edge runtimes are + // unsupported, so generate() rejects with E_UNSUPPORTED_RUNTIME at call + // time. The module itself must stay importable so bundlers don't choke. + const generateError = (await t.throwsAsync(async () => { + await browserEntry.generate(); + })) as GenerateError | undefined; + t.is(generateError?.code, 'E_UNSUPPORTED_RUNTIME'); + t.regex(generateError?.message ?? '', /browser|runtime/i); +}); + +test('package.json exports map covers node, browser, default, and types conditions', t => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ) as { + exports?: Record | string>; + }; + + const root = packageJson.exports?.['.']; + t.truthy(root, 'package.json exports map must include "."'); + t.is(typeof root, 'object'); + const conditions = root as Record; + t.is(conditions.types, './index.d.ts'); + t.is(conditions.browser, './browser.js'); + // Node entry is the wrapper at lib/index.js that upgrades thrown + // errors into `GenerateError` instances. The raw napi-rs binding at + // ./index.js is internal. + t.is(conditions.node, './lib/index.js'); + t.is(conditions.default, './lib/index.js'); +}); + +test('package metadata keeps the node-only packaging contract explicit', t => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ) as { + browser?: string; + description?: string; + files?: string[]; + main?: string; + types?: string; + }; + + t.is(packageJson.main, 'lib/index.js'); + t.is(packageJson.types, 'index.d.ts'); + t.is(packageJson.browser, 'browser.js'); + t.true(packageJson.files?.includes('browser.js') ?? false); + t.true(packageJson.files?.includes('lib/index.js') ?? false); + t.notRegex(packageJson.description ?? '', /Template project/i); +}); + +test('napi.targets is non-empty and lists only native triples (no wasm)', t => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ) as { napi?: { targets?: string[] } }; + const targets = packageJson.napi?.targets ?? []; + t.true(targets.length > 0, 'napi.targets must be non-empty'); + for (const triple of targets) { + t.false(triple.startsWith('wasm32-'), `unexpected wasm32 target: ${triple}`); + } +}); + +test('GenerateError always exposes code as a string', t => { + const err = new GenerateError({ message: 'm' }); + t.is(typeof err.code, 'string'); + t.not(err.code, undefined); +}); + +test('GenerateError always exposes path as a string', t => { + const err = new GenerateError({ message: 'm' }); + t.is(typeof err.path, 'string'); + t.not(err.path, undefined); +}); + +test('GenerateError.subcode is null (never undefined) when absent', t => { + const err = new GenerateError({ message: 'm' }); + t.is(err.subcode, null); +}); + +test('public surface is a fixed allow-list', t => { + const allowed = new Set(['generate', 'GenerateError', 'EmitTarget']); + // Node's CJS-to-ESM interop synthesises two bindings on `import * as`: + // - `'module.exports'`: the raw CJS object alongside per-property exports; + // - `'default'`: the same CJS object exposed as the default import. + // Neither is under the wrapper's control, so filter both out here. + // The wrapper-controlled absence of a default export is asserted via + // CommonJS `require()` in the dedicated test below. + const actual = new Set( + Object.keys(lib).filter(k => k !== 'module.exports' && k !== 'default'), + ); + for (const key of actual) { + t.true(allowed.has(key), `unexpected export: ${key}`); + } +}); + +test('public surface does not advertise a default export', t => { + // Read via CommonJS `require()` so we see the wrapper's actual + // `module.exports` object — `import * as` would synthesise a + // `default` binding via Node's CJS-to-ESM interop regardless of what + // the wrapper exposes, hiding the very thing we're asserting. + const mod = require('../lib/index.js'); + t.false('default' in mod, 'Node entry should not expose a default export'); +}); + +test('engines.node declares a >=18 floor', t => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ) as { engines?: { node?: string } }; + + const range = packageJson.engines?.node ?? ''; + // 18 is the floor pinned by the supported-Node policy (CI matrix, README, + // and native-binding ABI assumptions). Installs on older Node should fail + // at the manifest boundary, not at first CLI invocation. + const match = range.match(/>=\s*(\d+)/); + t.truthy(match, `engines.node must declare a >= lower bound (got '${range}')`); + const major = Number(match![1]); + t.true(major >= 18, `engines.node lower bound must be >= 18, got '${range}'`); +}); + +test('package.json engines.node matches README', t => { + const pkg = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ) as { engines?: { node?: string } }; + const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8'); + t.is(pkg.engines?.node, '>=18.0.0'); + t.regex(readme, /Requires Node\.js >= 18/); +}); + +test('patch-types narrows GeneratorDiagnostic and GenerateErrorPayload bodies', t => { + const dts = fs.readFileSync(path.join(repoRoot, 'index.d.ts'), 'utf8'); + + // Each narrowed line must appear exactly once in the generated d.ts + // (twice in total — once per interface — for the shared lines). + function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; + } + + t.is(countOccurrences(dts, ' code: DiagnosticCode'), 2); + t.is(countOccurrences(dts, ' subcode: DiagnosticSubcode | null'), 2); + t.is(countOccurrences(dts, " severity: 'warning' | 'error'"), 1); + + // The unpatched literals must not survive in the published surface. + t.false(dts.includes(' subcode?: string')); + t.false(dts.includes(' code: string')); + t.false(dts.includes(' severity: string')); +}); + +test('runtime docs document the current node-only boundary and browser stub', t => { + const runtimeDoc = fs.readFileSync( + path.join(repoRoot, 'website', 'src', 'content', 'docs', 'reference', 'runtime.md'), + 'utf8', + ); + + t.regex(runtimeDoc, /does not support browser runtimes/i); + t.regex(runtimeDoc, /E_UNSUPPORTED_RUNTIME/); +}); + +test('native.js includes a friendly unsupported-platform error with supported list', t => { + const nativeJs = fs.readFileSync(path.join(repoRoot, 'native.js'), 'utf8'); + + t.true( + nativeJs.includes('does not ship a native binary for'), + 'native.js must include the friendly unsupported-platform error message', + ); + + // Each supported platform key must be listed in the injected Set. + for (const platformKey of [ + 'darwin/x64', + 'darwin/arm64', + 'linux/x64', + 'linux/arm64', + 'win32/x64', + 'win32/arm64', + ]) { + t.true( + nativeJs.includes(`'${platformKey}'`), + `native.js must list '${platformKey}' as a supported platform`, + ); + } +}); + +test('lib/config.js is shipped in package.json files allow-list', t => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ) as { files?: string[] }; + t.true( + packageJson.files?.includes('lib/config.js') ?? false, + 'package.json files must include lib/config.js', + ); +}); + +test('package.json exports map exposes ./config subpath', t => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ) as { exports?: Record | string> }; + const subpath = packageJson.exports?.['./config']; + t.truthy(subpath, 'package.json exports must include "./config"'); + t.is(typeof subpath, 'object'); + const conditions = subpath as Record; + t.is(conditions.types, './index.d.ts'); + t.is(conditions.default, './lib/config.js'); +}); + +test('@avsystem/openapi-ng/config exports defineConfig as an identity function', t => { + const mod = require(path.join(repoRoot, 'lib', 'config.js')) as { + defineConfig: (c: T) => T; + }; + t.is(typeof mod.defineConfig, 'function'); + const input = { input: 'spec.yaml', output: './out' }; + t.is(mod.defineConfig(input), input); +}); diff --git a/__test__/rest-util-base-path.spec.ts b/__test__/rest-util-base-path.spec.ts new file mode 100644 index 0000000..86c1433 --- /dev/null +++ b/__test__/rest-util-base-path.spec.ts @@ -0,0 +1,92 @@ +// Mirror-test of the URL-join logic used inside `templates/angular/rest.util.ts` +// to prepend `OPENAPI_NG_BASE_PATH` to `CommonRequest.url`. We can't import the +// template directly here — it pulls in `@angular/common/http`, which needs +// Angular's JIT/platform bootstrapping that's heavy to stand up under plain +// Node — so we keep a parallel implementation pinned to the template's +// behaviour. The snapshot suite is the authoritative check that the template +// itself still contains the matching algorithm. +import test from 'ava'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function joinBasePath(base: string, url: string): string { + // Absolute URLs (https://…, etc.) bypass the configured basePath. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return url; + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; + const normalizedUrl = url.startsWith('/') ? url : `/${url}`; + return normalizedBase + normalizedUrl; +} + +test('joinBasePath: absolute base + leading-slash path', t => { + t.is(joinBasePath('https://api.example.com', '/pets'), 'https://api.example.com/pets'); +}); + +test('joinBasePath: trailing slash on base is normalised', t => { + t.is(joinBasePath('https://api.example.com/', '/pets'), 'https://api.example.com/pets'); +}); + +test('joinBasePath: base with subpath', t => { + t.is( + joinBasePath('https://api.example.com/v1', '/pets/123'), + 'https://api.example.com/v1/pets/123', + ); +}); + +test('joinBasePath: relative base + leading-slash path', t => { + t.is(joinBasePath('/api', '/pets'), '/api/pets'); +}); + +test('joinBasePath: url without leading slash gets one added', t => { + t.is(joinBasePath('/api', 'pets'), '/api/pets'); +}); + +test('joinBasePath: both trailing- and leading-slash collapse to one slash', t => { + t.is(joinBasePath('/api/', '/pets'), '/api/pets'); +}); + +test('joinBasePath: absolute http url on request bypasses basePath', t => { + t.is( + joinBasePath('https://api.example.com', 'http://other.example.com/pets'), + 'http://other.example.com/pets', + ); +}); + +test('joinBasePath: absolute https url on request bypasses basePath', t => { + t.is( + joinBasePath('/api', 'https://other.example.com/pets'), + 'https://other.example.com/pets', + ); +}); + +test('joinBasePath: protocol-relative urls are NOT bypassed', t => { + // `//host/path` is protocol-relative, not absolute by RFC 3986 scheme rules; + // the regex requires `scheme:` before `//`, so basePath still applies. + t.is(joinBasePath('/api', '//other.example.com/pets'), '/api//other.example.com/pets'); +}); + +// Pin the template against the parallel implementation: if the template's +// joinBasePath is ever edited away from this algorithm, this test surfaces +// the drift immediately (without booting Angular). +test('template `joinBasePath` source matches this parallel implementation', t => { + const templatePath = path.resolve( + __dirname, + '..', + 'templates', + 'angular', + 'rest.util.ts', + ); + const source = fs.readFileSync(templatePath, 'utf8'); + const match = source.match( + /function joinBasePath\(base: string, url: string\): string \{\s*([\s\S]*?)\n\}/u, + ); + t.truthy(match, 'expected to find joinBasePath in template'); + const body = (match![1] ?? '').replace(/\s+/gu, ' ').trim(); + t.is( + body, + "// Absolute URLs (https://…, etc.) bypass the configured basePath. if (/^[a-z][a-z0-9+.-]*:\\/\\//i.test(url)) return url; const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; const normalizedUrl = url.startsWith('/') ? url : `/${url}`; return normalizedBase + normalizedUrl;", + 'template joinBasePath drifted from the parallel test implementation', + ); +}); diff --git a/__test__/snapshots/generate-native/additional-properties-boolean.openapi.yaml.failure.json b/__test__/snapshots/generate-native/additional-properties-boolean.openapi.yaml.failure.json new file mode 100644 index 0000000..8823bf2 --- /dev/null +++ b/__test__/snapshots/generate-native/additional-properties-boolean.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: schema PermissiveBag combines additionalProperties with named object properties, which remains outside the supported subset.", + "path": "test/fixtures/additional-properties-boolean.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml.success.json b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml.success.json new file mode 100644 index 0000000..a0d03ae --- /dev/null +++ b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/additional-properties-false.openapi.yaml", + "specVersion": "3.0.3", + "title": "Additional Properties False", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..f240ff2 --- /dev/null +++ b/__test__/snapshots/generate-native/additional-properties-false.openapi.yaml/model.generated.ts @@ -0,0 +1,3 @@ +export interface StrictBag { + id: string; +} diff --git a/__test__/snapshots/generate-native/additional-properties.openapi.yaml.success.json b/__test__/snapshots/generate-native/additional-properties.openapi.yaml.success.json new file mode 100644 index 0000000..3f1bb3f --- /dev/null +++ b/__test__/snapshots/generate-native/additional-properties.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/additional-properties.openapi.yaml", + "specVersion": "3.0.3", + "title": "Petstore Additional Properties", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 5 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/additional-properties.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..2f0e970 --- /dev/null +++ b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/model.generated.ts @@ -0,0 +1,21 @@ +export interface Pet { + id: string; + name: string; +} + +export interface PetCatalog { + scope: 'available' | 'adopted' | 'foster'; + petsByBreed: Record; +} + +export interface PetMetadata { + spotlight: boolean; + tag: Tag; +} + +export type PetMetadataByTag = Record; + +export interface Tag { + id: number; + label: string; +} diff --git a/__test__/snapshots/generate-native/additional-properties.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..8911440 --- /dev/null +++ b/__test__/snapshots/generate-native/additional-properties.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { PetCatalog } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPetCatalog = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/allof-composition.openapi.yaml.success.json b/__test__/snapshots/generate-native/allof-composition.openapi.yaml.success.json new file mode 100644 index 0000000..2fc190b --- /dev/null +++ b/__test__/snapshots/generate-native/allof-composition.openapi.yaml.success.json @@ -0,0 +1,47 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/allof-composition.openapi.yaml", + "specVersion": "3.0.3", + "title": "AllOf Composition", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 3 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema AuditFields.createdAt declares format 'date-time', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/allof-composition.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema AuditFields.archivedAt declares format 'date-time', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/allof-composition.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema ContactFields.email declares format 'email', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/allof-composition.openapi.yaml", + "subcode": "format-dropped" + } + ], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/adopter.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/allof-composition.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..b32d848 --- /dev/null +++ b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/model.generated.ts @@ -0,0 +1,15 @@ +export type AdopterProfile = AuditFields & ContactFields & { + id: string; + name: string; + nickname?: string | null; +}; + +export interface AuditFields { + createdAt: string; + archivedAt?: string | null; +} + +export interface ContactFields { + email: string; + phone?: string; +} diff --git a/__test__/snapshots/generate-native/allof-composition.openapi.yaml/rest/adopter.rest.generated.ts b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/rest/adopter.rest.generated.ts new file mode 100644 index 0000000..200dfdc --- /dev/null +++ b/__test__/snapshots/generate-native/allof-composition.openapi.yaml/rest/adopter.rest.generated.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { AdopterProfile } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class AdopterRest { + + readonly getAdopterProfile = requestFactory( + (request: GetAdopterProfileParams) => { + const { adopterId } = request; + return { + method: 'GET', + url: `/adopters/${encodeURIComponent(adopterId)}`, + }; + }, + ); +} + +export interface GetAdopterProfileParams { + adopterId: string; +} diff --git a/__test__/snapshots/generate-native/anchor-fanout.openapi.yaml.failure.json b/__test__/snapshots/generate-native/anchor-fanout.openapi.yaml.failure.json new file mode 100644 index 0000000..c5f6640 --- /dev/null +++ b/__test__/snapshots/generate-native/anchor-fanout.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "Failed to decode OpenAPI input: YAML anchor expansion produced 15381905 bytes from 45147 bytes of source — 340× ratio exceeds the cap of 50×. The spec likely uses anchors with deep fan-out; inline the aliases or set OPENAPI_NG_MAX_EXPANSION_RATIO to override.", + "path": "test/fixtures/anchor-fanout.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/anchor-modest.openapi.yaml.success.json b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml.success.json new file mode 100644 index 0000000..93fc891 --- /dev/null +++ b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/anchor-modest.openapi.yaml", + "specVersion": "3.0.3", + "title": "anchor-modest", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 4 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/anchor-modest.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..d021e28 --- /dev/null +++ b/__test__/snapshots/generate-native/anchor-modest.openapi.yaml/model.generated.ts @@ -0,0 +1,28 @@ +export interface Audit { + createdAt?: string; + updatedAt?: string; +} + +export type Owner = { + createdAt?: string; + updatedAt?: string; +} & { + id?: string; + displayName?: string; +}; + +export type Pet = { + createdAt?: string; + updatedAt?: string; +} & { + id?: string; + name?: string; +}; + +export type Visit = { + createdAt?: string; + updatedAt?: string; +} & { + id?: string; + notes?: string; +}; diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml.success.json b/__test__/snapshots/generate-native/bench-large.openapi.yaml.success.json new file mode 100644 index 0000000..cf030d7 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml.success.json @@ -0,0 +1,40 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/bench-large.openapi.yaml", + "specVersion": "3.0.3", + "title": "Bench Large", + "pathCount": 30, + "operationCount": 60, + "schemaCount": 90 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/resource1.rest.generated.ts" + }, + { + "path": "rest/resource2.rest.generated.ts" + }, + { + "path": "rest/resource3.rest.generated.ts" + }, + { + "path": "rest/resource4.rest.generated.ts" + }, + { + "path": "rest/resource5.rest.generated.ts" + }, + { + "path": "rest/resource6.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..cb7c6d0 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/model.generated.ts @@ -0,0 +1,359 @@ +export interface Resource1 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource1Status; +} + +export interface Resource10 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource10Status; +} + +export type Resource10List = Resource10[]; + +export type Resource10Status = 'active' | 'archived' | 'pending'; + +export interface Resource11 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource11Status; +} + +export type Resource11List = Resource11[]; + +export type Resource11Status = 'active' | 'archived' | 'pending'; + +export interface Resource12 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource12Status; +} + +export type Resource12List = Resource12[]; + +export type Resource12Status = 'active' | 'archived' | 'pending'; + +export interface Resource13 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource13Status; +} + +export type Resource13List = Resource13[]; + +export type Resource13Status = 'active' | 'archived' | 'pending'; + +export interface Resource14 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource14Status; +} + +export type Resource14List = Resource14[]; + +export type Resource14Status = 'active' | 'archived' | 'pending'; + +export interface Resource15 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource15Status; +} + +export type Resource15List = Resource15[]; + +export type Resource15Status = 'active' | 'archived' | 'pending'; + +export interface Resource16 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource16Status; +} + +export type Resource16List = Resource16[]; + +export type Resource16Status = 'active' | 'archived' | 'pending'; + +export interface Resource17 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource17Status; +} + +export type Resource17List = Resource17[]; + +export type Resource17Status = 'active' | 'archived' | 'pending'; + +export interface Resource18 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource18Status; +} + +export type Resource18List = Resource18[]; + +export type Resource18Status = 'active' | 'archived' | 'pending'; + +export interface Resource19 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource19Status; +} + +export type Resource19List = Resource19[]; + +export type Resource19Status = 'active' | 'archived' | 'pending'; + +export type Resource1List = Resource1[]; + +export type Resource1Status = 'active' | 'archived' | 'pending'; + +export interface Resource2 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource2Status; +} + +export interface Resource20 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource20Status; +} + +export type Resource20List = Resource20[]; + +export type Resource20Status = 'active' | 'archived' | 'pending'; + +export interface Resource21 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource21Status; +} + +export type Resource21List = Resource21[]; + +export type Resource21Status = 'active' | 'archived' | 'pending'; + +export interface Resource22 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource22Status; +} + +export type Resource22List = Resource22[]; + +export type Resource22Status = 'active' | 'archived' | 'pending'; + +export interface Resource23 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource23Status; +} + +export type Resource23List = Resource23[]; + +export type Resource23Status = 'active' | 'archived' | 'pending'; + +export interface Resource24 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource24Status; +} + +export type Resource24List = Resource24[]; + +export type Resource24Status = 'active' | 'archived' | 'pending'; + +export interface Resource25 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource25Status; +} + +export type Resource25List = Resource25[]; + +export type Resource25Status = 'active' | 'archived' | 'pending'; + +export interface Resource26 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource26Status; +} + +export type Resource26List = Resource26[]; + +export type Resource26Status = 'active' | 'archived' | 'pending'; + +export interface Resource27 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource27Status; +} + +export type Resource27List = Resource27[]; + +export type Resource27Status = 'active' | 'archived' | 'pending'; + +export interface Resource28 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource28Status; +} + +export type Resource28List = Resource28[]; + +export type Resource28Status = 'active' | 'archived' | 'pending'; + +export interface Resource29 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource29Status; +} + +export type Resource29List = Resource29[]; + +export type Resource29Status = 'active' | 'archived' | 'pending'; + +export type Resource2List = Resource2[]; + +export type Resource2Status = 'active' | 'archived' | 'pending'; + +export interface Resource3 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource3Status; +} + +export interface Resource30 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource30Status; +} + +export type Resource30List = Resource30[]; + +export type Resource30Status = 'active' | 'archived' | 'pending'; + +export type Resource3List = Resource3[]; + +export type Resource3Status = 'active' | 'archived' | 'pending'; + +export interface Resource4 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource4Status; +} + +export type Resource4List = Resource4[]; + +export type Resource4Status = 'active' | 'archived' | 'pending'; + +export interface Resource5 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource5Status; +} + +export type Resource5List = Resource5[]; + +export type Resource5Status = 'active' | 'archived' | 'pending'; + +export interface Resource6 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource6Status; +} + +export type Resource6List = Resource6[]; + +export type Resource6Status = 'active' | 'archived' | 'pending'; + +export interface Resource7 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource7Status; +} + +export type Resource7List = Resource7[]; + +export type Resource7Status = 'active' | 'archived' | 'pending'; + +export interface Resource8 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource8Status; +} + +export type Resource8List = Resource8[]; + +export type Resource8Status = 'active' | 'archived' | 'pending'; + +export interface Resource9 { + id: string; + name: string; + description?: string | null; + tags?: string[]; + status?: Resource9Status; +} + +export type Resource9List = Resource9[]; + +export type Resource9Status = 'active' | 'archived' | 'pending'; diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource1.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource1.rest.generated.ts new file mode 100644 index 0000000..d5c702d --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource1.rest.generated.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + Resource1, + Resource1List, + Resource2, + Resource2List, + Resource3, + Resource3List, + Resource4, + Resource4List, + Resource5, + Resource5List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class Resource1Rest { + + readonly createResource1 = requestFactory( + (request: CreateResource1Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource1`, + body: body, + }; + }, + ); + + readonly createResource2 = requestFactory( + (request: CreateResource2Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource2`, + body: body, + }; + }, + ); + + readonly createResource3 = requestFactory( + (request: CreateResource3Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource3`, + body: body, + }; + }, + ); + + readonly createResource4 = requestFactory( + (request: CreateResource4Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource4`, + body: body, + }; + }, + ); + + readonly createResource5 = requestFactory( + (request: CreateResource5Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource5`, + body: body, + }; + }, + ); + + readonly getResource1 = requestFactory( + (request: GetResource1Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource1`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource2 = requestFactory( + (request: GetResource2Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource2`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource3 = requestFactory( + (request: GetResource3Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource3`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource4 = requestFactory( + (request: GetResource4Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource4`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource5 = requestFactory( + (request: GetResource5Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource5`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateResource1Params { + body: Resource1; +} + +export interface CreateResource2Params { + body: Resource2; +} + +export interface CreateResource3Params { + body: Resource3; +} + +export interface CreateResource4Params { + body: Resource4; +} + +export interface CreateResource5Params { + body: Resource5; +} + +export interface GetResource1Params { + limit?: number; +} + +export interface GetResource2Params { + limit?: number; +} + +export interface GetResource3Params { + limit?: number; +} + +export interface GetResource4Params { + limit?: number; +} + +export interface GetResource5Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource2.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource2.rest.generated.ts new file mode 100644 index 0000000..607c82b --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource2.rest.generated.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + Resource10, + Resource10List, + Resource6, + Resource6List, + Resource7, + Resource7List, + Resource8, + Resource8List, + Resource9, + Resource9List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class Resource2Rest { + + readonly createResource10 = requestFactory( + (request: CreateResource10Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource10`, + body: body, + }; + }, + ); + + readonly createResource6 = requestFactory( + (request: CreateResource6Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource6`, + body: body, + }; + }, + ); + + readonly createResource7 = requestFactory( + (request: CreateResource7Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource7`, + body: body, + }; + }, + ); + + readonly createResource8 = requestFactory( + (request: CreateResource8Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource8`, + body: body, + }; + }, + ); + + readonly createResource9 = requestFactory( + (request: CreateResource9Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource9`, + body: body, + }; + }, + ); + + readonly getResource10 = requestFactory( + (request: GetResource10Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource10`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource6 = requestFactory( + (request: GetResource6Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource6`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource7 = requestFactory( + (request: GetResource7Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource7`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource8 = requestFactory( + (request: GetResource8Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource8`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource9 = requestFactory( + (request: GetResource9Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource9`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateResource10Params { + body: Resource10; +} + +export interface CreateResource6Params { + body: Resource6; +} + +export interface CreateResource7Params { + body: Resource7; +} + +export interface CreateResource8Params { + body: Resource8; +} + +export interface CreateResource9Params { + body: Resource9; +} + +export interface GetResource10Params { + limit?: number; +} + +export interface GetResource6Params { + limit?: number; +} + +export interface GetResource7Params { + limit?: number; +} + +export interface GetResource8Params { + limit?: number; +} + +export interface GetResource9Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource3.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource3.rest.generated.ts new file mode 100644 index 0000000..d57096d --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource3.rest.generated.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + Resource11, + Resource11List, + Resource12, + Resource12List, + Resource13, + Resource13List, + Resource14, + Resource14List, + Resource15, + Resource15List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class Resource3Rest { + + readonly createResource11 = requestFactory( + (request: CreateResource11Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource11`, + body: body, + }; + }, + ); + + readonly createResource12 = requestFactory( + (request: CreateResource12Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource12`, + body: body, + }; + }, + ); + + readonly createResource13 = requestFactory( + (request: CreateResource13Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource13`, + body: body, + }; + }, + ); + + readonly createResource14 = requestFactory( + (request: CreateResource14Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource14`, + body: body, + }; + }, + ); + + readonly createResource15 = requestFactory( + (request: CreateResource15Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource15`, + body: body, + }; + }, + ); + + readonly getResource11 = requestFactory( + (request: GetResource11Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource11`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource12 = requestFactory( + (request: GetResource12Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource12`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource13 = requestFactory( + (request: GetResource13Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource13`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource14 = requestFactory( + (request: GetResource14Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource14`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource15 = requestFactory( + (request: GetResource15Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource15`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateResource11Params { + body: Resource11; +} + +export interface CreateResource12Params { + body: Resource12; +} + +export interface CreateResource13Params { + body: Resource13; +} + +export interface CreateResource14Params { + body: Resource14; +} + +export interface CreateResource15Params { + body: Resource15; +} + +export interface GetResource11Params { + limit?: number; +} + +export interface GetResource12Params { + limit?: number; +} + +export interface GetResource13Params { + limit?: number; +} + +export interface GetResource14Params { + limit?: number; +} + +export interface GetResource15Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource4.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource4.rest.generated.ts new file mode 100644 index 0000000..8d655f8 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource4.rest.generated.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + Resource16, + Resource16List, + Resource17, + Resource17List, + Resource18, + Resource18List, + Resource19, + Resource19List, + Resource20, + Resource20List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class Resource4Rest { + + readonly createResource16 = requestFactory( + (request: CreateResource16Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource16`, + body: body, + }; + }, + ); + + readonly createResource17 = requestFactory( + (request: CreateResource17Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource17`, + body: body, + }; + }, + ); + + readonly createResource18 = requestFactory( + (request: CreateResource18Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource18`, + body: body, + }; + }, + ); + + readonly createResource19 = requestFactory( + (request: CreateResource19Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource19`, + body: body, + }; + }, + ); + + readonly createResource20 = requestFactory( + (request: CreateResource20Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource20`, + body: body, + }; + }, + ); + + readonly getResource16 = requestFactory( + (request: GetResource16Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource16`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource17 = requestFactory( + (request: GetResource17Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource17`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource18 = requestFactory( + (request: GetResource18Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource18`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource19 = requestFactory( + (request: GetResource19Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource19`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource20 = requestFactory( + (request: GetResource20Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource20`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateResource16Params { + body: Resource16; +} + +export interface CreateResource17Params { + body: Resource17; +} + +export interface CreateResource18Params { + body: Resource18; +} + +export interface CreateResource19Params { + body: Resource19; +} + +export interface CreateResource20Params { + body: Resource20; +} + +export interface GetResource16Params { + limit?: number; +} + +export interface GetResource17Params { + limit?: number; +} + +export interface GetResource18Params { + limit?: number; +} + +export interface GetResource19Params { + limit?: number; +} + +export interface GetResource20Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource5.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource5.rest.generated.ts new file mode 100644 index 0000000..9af0b44 --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource5.rest.generated.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + Resource21, + Resource21List, + Resource22, + Resource22List, + Resource23, + Resource23List, + Resource24, + Resource24List, + Resource25, + Resource25List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class Resource5Rest { + + readonly createResource21 = requestFactory( + (request: CreateResource21Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource21`, + body: body, + }; + }, + ); + + readonly createResource22 = requestFactory( + (request: CreateResource22Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource22`, + body: body, + }; + }, + ); + + readonly createResource23 = requestFactory( + (request: CreateResource23Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource23`, + body: body, + }; + }, + ); + + readonly createResource24 = requestFactory( + (request: CreateResource24Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource24`, + body: body, + }; + }, + ); + + readonly createResource25 = requestFactory( + (request: CreateResource25Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource25`, + body: body, + }; + }, + ); + + readonly getResource21 = requestFactory( + (request: GetResource21Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource21`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource22 = requestFactory( + (request: GetResource22Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource22`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource23 = requestFactory( + (request: GetResource23Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource23`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource24 = requestFactory( + (request: GetResource24Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource24`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource25 = requestFactory( + (request: GetResource25Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource25`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateResource21Params { + body: Resource21; +} + +export interface CreateResource22Params { + body: Resource22; +} + +export interface CreateResource23Params { + body: Resource23; +} + +export interface CreateResource24Params { + body: Resource24; +} + +export interface CreateResource25Params { + body: Resource25; +} + +export interface GetResource21Params { + limit?: number; +} + +export interface GetResource22Params { + limit?: number; +} + +export interface GetResource23Params { + limit?: number; +} + +export interface GetResource24Params { + limit?: number; +} + +export interface GetResource25Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource6.rest.generated.ts b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource6.rest.generated.ts new file mode 100644 index 0000000..ea7eebe --- /dev/null +++ b/__test__/snapshots/generate-native/bench-large.openapi.yaml/rest/resource6.rest.generated.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { + Resource26, + Resource26List, + Resource27, + Resource27List, + Resource28, + Resource28List, + Resource29, + Resource29List, + Resource30, + Resource30List, +} from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class Resource6Rest { + + readonly createResource26 = requestFactory( + (request: CreateResource26Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource26`, + body: body, + }; + }, + ); + + readonly createResource27 = requestFactory( + (request: CreateResource27Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource27`, + body: body, + }; + }, + ); + + readonly createResource28 = requestFactory( + (request: CreateResource28Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource28`, + body: body, + }; + }, + ); + + readonly createResource29 = requestFactory( + (request: CreateResource29Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource29`, + body: body, + }; + }, + ); + + readonly createResource30 = requestFactory( + (request: CreateResource30Params) => { + const { body } = request; + return { + method: 'POST', + url: `/resource30`, + body: body, + }; + }, + ); + + readonly getResource26 = requestFactory( + (request: GetResource26Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource26`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource27 = requestFactory( + (request: GetResource27Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource27`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource28 = requestFactory( + (request: GetResource28Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource28`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource29 = requestFactory( + (request: GetResource29Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource29`, + params: httpParams({ limit }), + }; + }, + ); + + readonly getResource30 = requestFactory( + (request: GetResource30Params) => { + const { limit } = request; + return { + method: 'GET', + url: `/resource30`, + params: httpParams({ limit }), + }; + }, + ); +} + +export interface CreateResource26Params { + body: Resource26; +} + +export interface CreateResource27Params { + body: Resource27; +} + +export interface CreateResource28Params { + body: Resource28; +} + +export interface CreateResource29Params { + body: Resource29; +} + +export interface CreateResource30Params { + body: Resource30; +} + +export interface GetResource26Params { + limit?: number; +} + +export interface GetResource27Params { + limit?: number; +} + +export interface GetResource28Params { + limit?: number; +} + +export interface GetResource29Params { + limit?: number; +} + +export interface GetResource30Params { + limit?: number; +} diff --git a/__test__/snapshots/generate-native/body-content-type-xml.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-content-type-xml.openapi.yaml.failure.json new file mode 100644 index 0000000..f4d86fb --- /dev/null +++ b/__test__/snapshots/generate-native/body-content-type-xml.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "requestBody for POST /x: unsupported content type \"application/xml\". Use application/json, multipart/form-data, or application/x-www-form-urlencoded.", + "path": "test/fixtures/body-content-type-xml.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/body-multi-content.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multi-content.openapi.yaml.failure.json new file mode 100644 index 0000000..1c6b87e --- /dev/null +++ b/__test__/snapshots/generate-native/body-multi-content.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "requestBody for POST /x must declare exactly one content type.", + "path": "test/fixtures/body-multi-content.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/body-multipart-composed-field.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-composed-field.openapi.yaml.failure.json new file mode 100644 index 0000000..ac096ca --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-composed-field.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "body field 'variant' in POST /x: composed schemas are not supported in multipart bodies.", + "path": "test/fixtures/body-multipart-composed-field.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml.success.json b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml.success.json new file mode 100644 index 0000000..70716dd --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml.success.json @@ -0,0 +1,37 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/body-multipart-mixed-fields.openapi.yaml", + "specVersion": "3.0.3", + "title": "Multipart Mixed", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "requestBody for POST /pets/{petId}/avatar.avatar declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/body-multipart-mixed-fields.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "requestBody for POST /pets/{petId}/avatar.galleries declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/body-multipart-mixed-fields.openapi.yaml", + "subcode": "format-dropped" + } + ], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..64b29b3 --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-mixed-fields.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly updatePetAvatar = requestFactory( + (request: UpdatePetAvatarParams) => { + const { petId, avatar, galleries, nickname, status, tagIds } = request; + return { + method: 'POST', + url: `/pets/${encodeURIComponent(petId)}/avatar`, + body: ((): FormData => { + const fd = new FormData(); + fd.append('avatar', avatar); + for (const v of galleries) fd.append('galleries', v); + if (nickname !== undefined) fd.append('nickname', String(nickname)); + fd.append('status', String(status)); + if (tagIds !== undefined) for (const v of tagIds) fd.append('tagIds', String(v)); + return fd; + })(), + }; + }, + ); +} + +export interface UpdatePetAvatarParams { + petId: string; + avatar: Blob | File; + galleries: (Blob | File)[]; + nickname?: string; + status: string; + tagIds?: number[]; +} diff --git a/__test__/snapshots/generate-native/body-multipart-nested-object.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-nested-object.openapi.yaml.failure.json new file mode 100644 index 0000000..39e9fc9 --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-nested-object.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "body field 'metadata' in POST /x: nested objects are not supported in multipart bodies.", + "path": "test/fixtures/body-multipart-nested-object.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/body-multipart-non-object.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-non-object.openapi.yaml.failure.json new file mode 100644 index 0000000..44dfb52 --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-non-object.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "requestBody for POST /x: multipart body schema must resolve to an object.", + "path": "test/fixtures/body-multipart-non-object.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/body-multipart-open-schema.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-multipart-open-schema.openapi.yaml.failure.json new file mode 100644 index 0000000..ed75af4 --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-open-schema.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "requestBody for POST /x: multipart bodies must not declare additionalProperties; every field must be enumerated.", + "path": "test/fixtures/body-multipart-open-schema.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml.success.json b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml.success.json new file mode 100644 index 0000000..b3e7f86 --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/body-multipart-ref-to-named-object.openapi.yaml", + "specVersion": "3.0.3", + "title": "Multipart Ref", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/upload.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..86fa774 --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/model.generated.ts @@ -0,0 +1,4 @@ +export interface UploadForm { + title: string; + description?: string; +} diff --git a/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/rest/upload.rest.generated.ts b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/rest/upload.rest.generated.ts new file mode 100644 index 0000000..1f1c5d3 --- /dev/null +++ b/__test__/snapshots/generate-native/body-multipart-ref-to-named-object.openapi.yaml/rest/upload.rest.generated.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class UploadRest { + + readonly createUpload = requestFactory( + (request: CreateUploadParams) => { + const { description, title } = request; + return { + method: 'POST', + url: `/uploads`, + body: ((): FormData => { + const fd = new FormData(); + if (description !== undefined) fd.append('description', String(description)); + fd.append('title', String(title)); + return fd; + })(), + }; + }, + ); +} + +export interface CreateUploadParams { + description?: string; + title: string; +} diff --git a/__test__/snapshots/generate-native/body-urlencoded-binary-field.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-urlencoded-binary-field.openapi.yaml.failure.json new file mode 100644 index 0000000..1654bb7 --- /dev/null +++ b/__test__/snapshots/generate-native/body-urlencoded-binary-field.openapi.yaml.failure.json @@ -0,0 +1,14 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "body field 'avatar' in POST /x: binary fields are not supported in application/x-www-form-urlencoded.", + "path": "test/fixtures/body-urlencoded-binary-field.openapi.yaml", + "warnings": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "requestBody for POST /x.avatar declares format 'binary', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/body-urlencoded-binary-field.openapi.yaml", + "subcode": "format-dropped" + } + ] +} diff --git a/__test__/snapshots/generate-native/body-urlencoded-nested-object.openapi.yaml.failure.json b/__test__/snapshots/generate-native/body-urlencoded-nested-object.openapi.yaml.failure.json new file mode 100644 index 0000000..95f4942 --- /dev/null +++ b/__test__/snapshots/generate-native/body-urlencoded-nested-object.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "body field 'metadata' in POST /x: nested objects are not supported in urlencoded bodies.", + "path": "test/fixtures/body-urlencoded-nested-object.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml.success.json b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml.success.json new file mode 100644 index 0000000..97b482b --- /dev/null +++ b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/body-urlencoded-scalar-and-array.openapi.yaml", + "specVersion": "3.0.3", + "title": "Urlencoded", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/search.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml/rest/search.rest.generated.ts b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml/rest/search.rest.generated.ts new file mode 100644 index 0000000..3023fd8 --- /dev/null +++ b/__test__/snapshots/generate-native/body-urlencoded-scalar-and-array.openapi.yaml/rest/search.rest.generated.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class SearchRest { + + readonly submitSearch = requestFactory( + (request: SubmitSearchParams) => { + const { query, tagIds } = request; + return { + method: 'POST', + url: `/search`, + body: ((): URLSearchParams => { + const params = new URLSearchParams(); + params.append('query', String(query)); + if (tagIds !== undefined) for (const v of tagIds) params.append('tagIds', String(v)); + return params; + })(), + }; + }, + ); +} + +export interface SubmitSearchParams { + query: string; + tagIds?: number[]; +} diff --git a/__test__/snapshots/generate-native/circular-allof.openapi.yaml.success.json b/__test__/snapshots/generate-native/circular-allof.openapi.yaml.success.json new file mode 100644 index 0000000..2a58ffb --- /dev/null +++ b/__test__/snapshots/generate-native/circular-allof.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/circular-allof.openapi.yaml", + "specVersion": "3.0.3", + "title": "Circular AllOf (Bottoms Out)", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 6 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/circular-allof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/circular-allof.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..0aa8f22 --- /dev/null +++ b/__test__/snapshots/generate-native/circular-allof.openapi.yaml/model.generated.ts @@ -0,0 +1,24 @@ +export interface BaseAuditFields { + createdAt: string; + updatedAt?: string | null; +} + +export type DeepRecord = Layer4 & { + tier5?: string; +}; + +export type Layer1 = BaseAuditFields & { + tier1?: string; +}; + +export type Layer2 = Layer1 & { + tier2?: string; +}; + +export type Layer3 = Layer2 & { + tier3?: string; +}; + +export type Layer4 = Layer3 & { + tier4?: string; +}; diff --git a/__test__/snapshots/generate-native/deep-nested-allof.openapi.yaml.failure.json b/__test__/snapshots/generate-native/deep-nested-allof.openapi.yaml.failure.json new file mode 100644 index 0000000..42c9f4c --- /dev/null +++ b/__test__/snapshots/generate-native/deep-nested-allof.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: schema Deep composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 composition member 1 nesting exceeds 32 levels (likely cyclic or pathological spec).", + "path": "test/fixtures/deep-nested-allof.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml.success.json b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml.success.json new file mode 100644 index 0000000..f7fb2b2 --- /dev/null +++ b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/deprecated-fields.openapi.yaml", + "specVersion": "3.0.3", + "title": "Deprecated Fields", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 2 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..dbba123 --- /dev/null +++ b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/model.generated.ts @@ -0,0 +1,15 @@ +/** + * Adoption state, legacy spelling. + * @deprecated + */ +export type LegacyPetStatus = 'available' | 'sold'; + +export interface Pet { + id: string; + /** + * Legacy numeric tag id; use `tagIds` instead. + * @deprecated + */ + legacyTagId?: number; + tagIds?: number[]; +} diff --git a/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..6b1e9d6 --- /dev/null +++ b/__test__/snapshots/generate-native/deprecated-fields.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { Pet } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + /** + * Retrieve a single pet + * + * Use `getPetById` instead — this endpoint will be removed. + * @deprecated + */ + readonly getPet = requestFactory( + (request: GetPetParams) => { + const { petId } = request; + return { + method: 'GET', + url: `/pets/${encodeURIComponent(petId)}`, + }; + }, + ); +} + +export interface GetPetParams { + petId: string; +} diff --git a/__test__/snapshots/generate-native/discriminated-union.openapi.yaml.success.json b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml.success.json new file mode 100644 index 0000000..6af806a --- /dev/null +++ b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/discriminated-union.openapi.yaml", + "specVersion": "3.0.3", + "title": "Discriminated Union", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 3 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/discriminated-union.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..49ed92a --- /dev/null +++ b/__test__/snapshots/generate-native/discriminated-union.openapi.yaml/model.generated.ts @@ -0,0 +1,11 @@ +export interface Cat { + kind: 'cat'; + lives: number; +} + +export interface Dog { + kind: 'dog'; + breed: string; +} + +export type PetUnion = Cat | Dog; diff --git a/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml.success.json b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml.success.json new file mode 100644 index 0000000..131f05d --- /dev/null +++ b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/discriminator-allof.openapi.yaml", + "specVersion": "3.0.3", + "title": "discriminator-allof", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 4 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..77a2faf --- /dev/null +++ b/__test__/snapshots/generate-native/discriminator-allof.openapi.yaml/model.generated.ts @@ -0,0 +1,15 @@ +export interface Animal { + name: string; +} + +export type Cat = Animal & { + kind: 'cat'; + whiskers?: number; +}; + +export type Dog = Animal & { + kind: 'dog'; + barkLoudness?: string; +}; + +export type Pet = Cat | Dog; diff --git a/__test__/snapshots/generate-native/discriminator-mapping-external-ref.openapi.yaml.failure.json b/__test__/snapshots/generate-native/discriminator-mapping-external-ref.openapi.yaml.failure.json new file mode 100644 index 0000000..75c1640 --- /dev/null +++ b/__test__/snapshots/generate-native/discriminator-mapping-external-ref.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: schema Pet uses unsupported reference http://example.com/schemas/Cat.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/discriminator-mapping-external-ref.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml.success.json b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml.success.json new file mode 100644 index 0000000..2cafe00 --- /dev/null +++ b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/discriminator-mapping.openapi.yaml", + "specVersion": "3.0.3", + "title": "discriminator-mapping", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 3 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..03dfbf5 --- /dev/null +++ b/__test__/snapshots/generate-native/discriminator-mapping.openapi.yaml/model.generated.ts @@ -0,0 +1,9 @@ +export interface Cat { + kind: 'feline'; +} + +export interface Dog { + kind: 'canine'; +} + +export type Pet = Cat | Dog; diff --git a/__test__/snapshots/generate-native/discriminator-missing-property.openapi.yaml.failure.json b/__test__/snapshots/generate-native/discriminator-missing-property.openapi.yaml.failure.json new file mode 100644 index 0000000..e33b6fa --- /dev/null +++ b/__test__/snapshots/generate-native/discriminator-missing-property.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "Failed to validate spec: oneOf member 'Cat' does not declare the discriminator property 'kind'. Add the property to the member schema (typically as `type: string`) or remove the discriminator.", + "path": "test/fixtures/discriminator-missing-property.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/empty-parameter.openapi.yaml.failure.json b/__test__/snapshots/generate-native/empty-parameter.openapi.yaml.failure.json new file mode 100644 index 0000000..5ebffcd --- /dev/null +++ b/__test__/snapshots/generate-native/empty-parameter.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: parameter filter for GET /pets uses an empty schema, which is outside the supported subset.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/empty-parameter.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/empty-shapes.openapi.yaml.success.json b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml.success.json new file mode 100644 index 0000000..b02bc14 --- /dev/null +++ b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/empty-shapes.openapi.yaml", + "specVersion": "3.0.3", + "title": "Empty Schema Shapes", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 4 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/empty-shapes.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..54250ed --- /dev/null +++ b/__test__/snapshots/generate-native/empty-shapes.openapi.yaml/model.generated.ts @@ -0,0 +1,13 @@ +export type AnyValue = unknown; + +export type EmptyObject = Record; + +export type EmptyObjectWithProperties = Record; + +export interface ShapeContainer { + anything: unknown; + emptyInline: Record; + emptyInlineWithProperties?: Record; + emptyArray: unknown[]; + emptyMap: Record; +} diff --git a/__test__/snapshots/generate-native/external-ref.openapi.yaml.failure.json b/__test__/snapshots/generate-native/external-ref.openapi.yaml.failure.json new file mode 100644 index 0000000..09d4353 --- /dev/null +++ b/__test__/snapshots/generate-native/external-ref.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: schema Pet.owner uses unsupported reference shared.yaml#/components/schemas/Owner.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/external-ref.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/field-collision.openapi.yaml.failure.json b/__test__/snapshots/generate-native/field-collision.openapi.yaml.failure.json new file mode 100644 index 0000000..b5c073c --- /dev/null +++ b/__test__/snapshots/generate-native/field-collision.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_POLICY_VIOLATION", + "message": "operationId 'createUser': body fields [id] duplicate path/query parameter names. Rename the colliding fields in the OpenAPI spec, or hoist the body schema to a named `$ref` so it nests under `body`.", + "path": "test/fixtures/field-collision.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/header-param.openapi.yaml.success.json b/__test__/snapshots/generate-native/header-param.openapi.yaml.success.json new file mode 100644 index 0000000..9ca916a --- /dev/null +++ b/__test__/snapshots/generate-native/header-param.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/header-param.openapi.yaml", + "specVersion": "3.0.3", + "title": "Header Param", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/header-param.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/header-param.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..873b213 --- /dev/null +++ b/__test__/snapshots/generate-native/header-param.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPets = requestFactory( + (request: ListPetsParams) => { + const { headers } = request; + return { + method: 'GET', + url: `/pets`, + headers, + }; + }, + ); +} + +export interface ListPetsParams { + headers: { + 'X-Api-Key': string; + }; +} diff --git a/__test__/snapshots/generate-native/inline-model.openapi.yaml.success.json b/__test__/snapshots/generate-native/inline-model.openapi.yaml.success.json new file mode 100644 index 0000000..d316b7e --- /dev/null +++ b/__test__/snapshots/generate-native/inline-model.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/inline-model.openapi.yaml", + "specVersion": "3.0.3", + "title": "Inline Model Schemas", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/inline-model.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/inline-model.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..e13afa5 --- /dev/null +++ b/__test__/snapshots/generate-native/inline-model.openapi.yaml/model.generated.ts @@ -0,0 +1,17 @@ +export interface PetProfile { + id: string; + details: { + displayName: string; + address: { + city: string; + postalCode?: string | null; + }; + }; + labelsByLocale: Record; + visits: { + visitedAt: string; + notes?: string | null; + }[]; +} diff --git a/__test__/snapshots/generate-native/inline-parameter.openapi.yaml.failure.json b/__test__/snapshots/generate-native/inline-parameter.openapi.yaml.failure.json new file mode 100644 index 0000000..a5ea18d --- /dev/null +++ b/__test__/snapshots/generate-native/inline-parameter.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: parameter filter for GET /pets uses an inline object schema, which is outside the supported subset.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/inline-parameter.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/invalid-enum-type.openapi.yaml.failure.json b/__test__/snapshots/generate-native/invalid-enum-type.openapi.yaml.failure.json new file mode 100644 index 0000000..ac2e6a2 --- /dev/null +++ b/__test__/snapshots/generate-native/invalid-enum-type.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: schema InvalidEnum enum is supported only for string schemas, found type integer.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/invalid-enum-type.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/invalid-enum-value.openapi.json.failure.json b/__test__/snapshots/generate-native/invalid-enum-value.openapi.json.failure.json new file mode 100644 index 0000000..ff5a80e --- /dev/null +++ b/__test__/snapshots/generate-native/invalid-enum-value.openapi.json.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: schema InvalidEnum enum values must not contain null bytes.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/invalid-enum-value.openapi.json", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml.success.json b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml.success.json new file mode 100644 index 0000000..feed96f --- /dev/null +++ b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/jsdoc-descriptions.openapi.yaml", + "specVersion": "3.0.3", + "title": "JSDoc Descriptions", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 2 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..dd0211f --- /dev/null +++ b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/model.generated.ts @@ -0,0 +1,19 @@ +/** + * A pet that is up for adoption. + */ +export interface Pet { + /** + * Stable identifier across renames. + */ + id: string; + status: PetStatus; + /** + * Optional informal name used in marketing copy. + */ + nickname?: string; +} + +/** + * Adoption state of the pet. + */ +export type PetStatus = 'available' | 'pending' | 'adopted'; diff --git a/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..187dd8d --- /dev/null +++ b/__test__/snapshots/generate-native/jsdoc-descriptions.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { Pet } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + /** + * Retrieve a single pet + * + * Returns the full pet record by id. The returned shape + * matches `Pet` exactly — no partial fetches. + */ + readonly getPet = requestFactory( + (request: GetPetParams) => { + const { petId } = request; + return { + method: 'GET', + url: `/pets/${encodeURIComponent(petId)}`, + }; + }, + ); +} + +export interface GetPetParams { + petId: string; +} diff --git a/__test__/snapshots/generate-native/large-enum.openapi.yaml.success.json b/__test__/snapshots/generate-native/large-enum.openapi.yaml.success.json new file mode 100644 index 0000000..508b100 --- /dev/null +++ b/__test__/snapshots/generate-native/large-enum.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/large-enum.openapi.yaml", + "specVersion": "3.0.3", + "title": "Large Enum", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 2 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/large-enum.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/large-enum.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..88ad02a --- /dev/null +++ b/__test__/snapshots/generate-native/large-enum.openapi.yaml/model.generated.ts @@ -0,0 +1,56 @@ +export interface AddressBook { + state: UsState; + compactStatus?: 'active' | 'sealed'; +} + +export type UsState = + | 'alabama' + | 'alaska' + | 'arizona' + | 'arkansas' + | 'california' + | 'colorado' + | 'connecticut' + | 'delaware' + | 'florida' + | 'georgia' + | 'hawaii' + | 'idaho' + | 'illinois' + | 'indiana' + | 'iowa' + | 'kansas' + | 'kentucky' + | 'louisiana' + | 'maine' + | 'maryland' + | 'massachusetts' + | 'michigan' + | 'minnesota' + | 'mississippi' + | 'missouri' + | 'montana' + | 'nebraska' + | 'nevada' + | 'new-hampshire' + | 'new-jersey' + | 'new-mexico' + | 'new-york' + | 'north-carolina' + | 'north-dakota' + | 'ohio' + | 'oklahoma' + | 'oregon' + | 'pennsylvania' + | 'rhode-island' + | 'south-carolina' + | 'south-dakota' + | 'tennessee' + | 'texas' + | 'utah' + | 'vermont' + | 'virginia' + | 'washington' + | 'west-virginia' + | 'wisconsin' + | 'wyoming'; diff --git a/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml.success.json b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml.success.json new file mode 100644 index 0000000..851e022 --- /dev/null +++ b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/multi-tag-operation.openapi.yaml", + "specVersion": "3.0.3", + "title": "Multi Tag Operation", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 2 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..30d78df --- /dev/null +++ b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/model.generated.ts @@ -0,0 +1,5 @@ +export interface Pet { + id: string; +} + +export type PetList = Pet[]; diff --git a/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..d851027 --- /dev/null +++ b/__test__/snapshots/generate-native/multi-tag-operation.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { PetList } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPetsAndShelters = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/multi-warning.openapi.yaml.success.json b/__test__/snapshots/generate-native/multi-warning.openapi.yaml.success.json new file mode 100644 index 0000000..a5ed995 --- /dev/null +++ b/__test__/snapshots/generate-native/multi-warning.openapi.yaml.success.json @@ -0,0 +1,40 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/multi-warning.openapi.yaml", + "specVersion": "3.0.3", + "title": "Multi Warning", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 1 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "operationId 'listPets': parameter 'sessionId' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", + "path": "test/fixtures/multi-warning.openapi.yaml", + "subcode": "unsupported-parameter-location" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "operationId 'listPets': parameter 'trackingId' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", + "path": "test/fixtures/multi-warning.openapi.yaml", + "subcode": "unsupported-parameter-location" + } + ], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/multi-warning.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..d9dd182 --- /dev/null +++ b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/model.generated.ts @@ -0,0 +1,3 @@ +export interface Pet { + id: string; +} diff --git a/__test__/snapshots/generate-native/multi-warning.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..81ee94e --- /dev/null +++ b/__test__/snapshots/generate-native/multi-warning.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { Pet } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml.success.json b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml.success.json new file mode 100644 index 0000000..b3cd40b --- /dev/null +++ b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/nullable-oneof.openapi.yaml", + "specVersion": "3.0.3", + "title": "Nullable OneOf", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 4 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..38cee94 --- /dev/null +++ b/__test__/snapshots/generate-native/nullable-oneof.openapi.yaml/model.generated.ts @@ -0,0 +1,16 @@ +export interface Cat { + kind: 'cat'; + whiskers?: number; +} + +export interface Dog { + kind: 'dog'; + breed?: string; +} + +export type Pet = Cat | Dog | null; + +export interface Profile { + id: string; + favoritePet?: Cat | Dog | null; +} diff --git a/__test__/snapshots/generate-native/nullable-optional.openapi.yaml.success.json b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml.success.json new file mode 100644 index 0000000..526dc4d --- /dev/null +++ b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/nullable-optional.openapi.yaml", + "specVersion": "3.0.3", + "title": "Nullable optional fixture", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/nullable-optional.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..b381ffd --- /dev/null +++ b/__test__/snapshots/generate-native/nullable-optional.openapi.yaml/model.generated.ts @@ -0,0 +1,7 @@ +export interface Profile { + id: string; + displayName: string; + nickname?: string | null; + bio?: string; + legalName?: string | null; +} diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json.success.json b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json.success.json new file mode 100644 index 0000000..ccc72df --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json.success.json @@ -0,0 +1,36 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/oneof-anyof-composition.openapi.json", + "specVersion": "3.0.3", + "title": "OneOf AnyOf Composition", + "pathCount": 2, + "operationCount": 2, + "schemaCount": 8 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema ContactEmail.email declares format 'email', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/oneof-anyof-composition.openapi.json", + "subcode": "format-dropped" + } + ], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/adoption-request.rest.generated.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/model.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/model.generated.ts new file mode 100644 index 0000000..76b2b27 --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/model.generated.ts @@ -0,0 +1,35 @@ +export interface AdoptionDecision { + approved: boolean; + matchedPet?: PetUnion; + reviewerNote?: string | null; +} + +export interface AdoptionRequest { + applicantName: string; + preferredPet?: PetUnion; + contact: ContactEmail | ContactPhone; + notes?: string; + referralCode?: string | null; +} + +export interface Cat { + id: string; + lives: number; +} + +export interface ContactEmail { + email: string; +} + +export interface ContactPhone { + phone: string; +} + +export interface Dog { + id: string; + breed: string; +} + +export type PetUnion = Cat | Dog; + +export type PetUnionList = PetUnion[]; diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/adoption-request.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/adoption-request.rest.generated.ts new file mode 100644 index 0000000..aff3c12 --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/adoption-request.rest.generated.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { AdoptionDecision, AdoptionRequest } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class AdoptionRequestRest { + + readonly createAdoptionRequest = requestFactory( + (request: CreateAdoptionRequestParams) => { + const { body } = request; + return { + method: 'POST', + url: `/adoption-request`, + body: body, + }; + }, + ); +} + +export interface CreateAdoptionRequestParams { + body: AdoptionRequest; +} diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/pet.rest.generated.ts new file mode 100644 index 0000000..65e57e6 --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.json/rest/pet.rest.generated.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { PetUnionList } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listUnionPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/union-pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml.success.json b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml.success.json new file mode 100644 index 0000000..8321830 --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml.success.json @@ -0,0 +1,36 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/oneof-anyof-composition.openapi.yaml", + "specVersion": "3.0.3", + "title": "OneOf AnyOf Composition", + "pathCount": 2, + "operationCount": 2, + "schemaCount": 8 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema ContactEmail.email declares format 'email', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/oneof-anyof-composition.openapi.yaml", + "subcode": "format-dropped" + } + ], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/adoption-request.rest.generated.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..76b2b27 --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/model.generated.ts @@ -0,0 +1,35 @@ +export interface AdoptionDecision { + approved: boolean; + matchedPet?: PetUnion; + reviewerNote?: string | null; +} + +export interface AdoptionRequest { + applicantName: string; + preferredPet?: PetUnion; + contact: ContactEmail | ContactPhone; + notes?: string; + referralCode?: string | null; +} + +export interface Cat { + id: string; + lives: number; +} + +export interface ContactEmail { + email: string; +} + +export interface ContactPhone { + phone: string; +} + +export interface Dog { + id: string; + breed: string; +} + +export type PetUnion = Cat | Dog; + +export type PetUnionList = PetUnion[]; diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/adoption-request.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/adoption-request.rest.generated.ts new file mode 100644 index 0000000..aff3c12 --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/adoption-request.rest.generated.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { AdoptionDecision, AdoptionRequest } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class AdoptionRequestRest { + + readonly createAdoptionRequest = requestFactory( + (request: CreateAdoptionRequestParams) => { + const { body } = request; + return { + method: 'POST', + url: `/adoption-request`, + body: body, + }; + }, + ); +} + +export interface CreateAdoptionRequestParams { + body: AdoptionRequest; +} diff --git a/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..65e57e6 --- /dev/null +++ b/__test__/snapshots/generate-native/oneof-anyof-composition.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { PetUnionList } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listUnionPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/union-pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.json.success.json b/__test__/snapshots/generate-native/petstore-minimal.openapi.json.success.json new file mode 100644 index 0000000..48256bd --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.json.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/petstore-minimal.openapi.json", + "specVersion": "3.0.3", + "title": "Petstore Minimal", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.json/model.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/model.generated.ts new file mode 100644 index 0000000..de123f0 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/model.generated.ts @@ -0,0 +1 @@ +export type Pet = Record; diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.json/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/rest/pet.rest.generated.ts new file mode 100644 index 0000000..1f999d4 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.json/rest/pet.rest.generated.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml.success.json b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml.success.json new file mode 100644 index 0000000..1ab70c5 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/petstore-minimal.openapi.yaml", + "specVersion": "3.0.3", + "title": "Petstore Minimal", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..de123f0 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/model.generated.ts @@ -0,0 +1 @@ +export type Pet = Record; diff --git a/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..1f999d4 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-minimal.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.json.success.json b/__test__/snapshots/generate-native/petstore-rich.openapi.json.success.json new file mode 100644 index 0000000..7228b16 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-rich.openapi.json.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/petstore-rich.openapi.json", + "specVersion": "3.0.3", + "title": "Petstore Rich", + "pathCount": 2, + "operationCount": 3, + "schemaCount": 6 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.json/model.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.json/model.generated.ts new file mode 100644 index 0000000..ce16b3c --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-rich.openapi.json/model.generated.ts @@ -0,0 +1,24 @@ +export interface Pet { + id: PetId; + name: string; + status: PetStatus; + tags: Tag[]; + nickname?: string | null; +} + +export type PetId = string; + +export type PetList = Pet[]; + +export type PetStatus = 'available' | 'pending' | 'sold'; + +export interface Tag { + id: number; + label: string; +} + +export interface UpdatePetRequest { + status: PetStatus; + tagIds: number[]; + nickname?: string | null; +} diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.json/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.json/rest/pet.rest.generated.ts new file mode 100644 index 0000000..734f184 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-rich.openapi.json/rest/pet.rest.generated.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { Pet, PetId, PetList, UpdatePetRequest } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly getPet = requestFactory( + (request: GetPetParams) => { + const { petId } = request; + return { + method: 'GET', + url: `/pets/${encodeURIComponent(petId)}`, + }; + }, + ); + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); + + readonly updatePet = requestFactory( + (request: UpdatePetParams) => { + const { petId, includeHistory, body } = request; + return { + method: 'POST', + url: `/pets/${encodeURIComponent(petId)}`, + params: httpParams({ includeHistory }), + body: body, + }; + }, + ); +} + +export interface GetPetParams { + petId: PetId; +} + +export interface UpdatePetParams { + petId: PetId; + includeHistory?: boolean; + body: UpdatePetRequest; +} diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.invalid-mapped-type.failure.json b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.invalid-mapped-type.failure.json new file mode 100644 index 0000000..ddbd49d --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.invalid-mapped-type.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_INVALID_OPTION", + "message": "Failed to resolve generation options: mapped schema MissingSchema does not exist in the IR.", + "path": "test/fixtures/petstore-rich.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.success.json b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.success.json new file mode 100644 index 0000000..0cbc0ec --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/petstore-rich.openapi.yaml", + "specVersion": "3.0.3", + "title": "Petstore Rich", + "pathCount": 2, + "operationCount": 3, + "schemaCount": 6 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..ce16b3c --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/model.generated.ts @@ -0,0 +1,24 @@ +export interface Pet { + id: PetId; + name: string; + status: PetStatus; + tags: Tag[]; + nickname?: string | null; +} + +export type PetId = string; + +export type PetList = Pet[]; + +export type PetStatus = 'available' | 'pending' | 'sold'; + +export interface Tag { + id: number; + label: string; +} + +export interface UpdatePetRequest { + status: PetStatus; + tagIds: number[]; + nickname?: string | null; +} diff --git a/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..734f184 --- /dev/null +++ b/__test__/snapshots/generate-native/petstore-rich.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { httpParams, requestFactory } from '../rest.util'; +import type { Pet, PetId, PetList, UpdatePetRequest } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly getPet = requestFactory( + (request: GetPetParams) => { + const { petId } = request; + return { + method: 'GET', + url: `/pets/${encodeURIComponent(petId)}`, + }; + }, + ); + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); + + readonly updatePet = requestFactory( + (request: UpdatePetParams) => { + const { petId, includeHistory, body } = request; + return { + method: 'POST', + url: `/pets/${encodeURIComponent(petId)}`, + params: httpParams({ includeHistory }), + body: body, + }; + }, + ); +} + +export interface GetPetParams { + petId: PetId; +} + +export interface UpdatePetParams { + petId: PetId; + includeHistory?: boolean; + body: UpdatePetRequest; +} diff --git a/__test__/snapshots/generate-native/recursive-model.openapi.yaml.success.json b/__test__/snapshots/generate-native/recursive-model.openapi.yaml.success.json new file mode 100644 index 0000000..d2fbdce --- /dev/null +++ b/__test__/snapshots/generate-native/recursive-model.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/recursive-model.openapi.yaml", + "specVersion": "3.0.3", + "title": "Recursive Model", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 3 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/recursive-model.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/recursive-model.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..de853d2 --- /dev/null +++ b/__test__/snapshots/generate-native/recursive-model.openapi.yaml/model.generated.ts @@ -0,0 +1,14 @@ +export interface Category { + name: string; + subcategories: Category[]; +} + +export interface Person { + name: string; + favoritePet?: Pet; +} + +export interface Pet { + name: string; + owner?: Person; +} diff --git a/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml.success.json b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml.success.json new file mode 100644 index 0000000..eb4c6f2 --- /dev/null +++ b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/recursive-oneof.openapi.yaml", + "specVersion": "3.0.3", + "title": "Recursive OneOf", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..ca4b773 --- /dev/null +++ b/__test__/snapshots/generate-native/recursive-oneof.openapi.yaml/model.generated.ts @@ -0,0 +1,5 @@ +export type TreeNode = { + leaf: string; +} | { + children: TreeNode[]; +}; diff --git a/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml.success.json b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml.success.json new file mode 100644 index 0000000..0e6168b --- /dev/null +++ b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/reserved-prop-names.openapi.yaml", + "specVersion": "3.0.3", + "title": "Reserved And Non-Identifier Property Names", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 1 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..e620a7e --- /dev/null +++ b/__test__/snapshots/generate-native/reserved-prop-names.openapi.yaml/model.generated.ts @@ -0,0 +1,8 @@ +export interface NonIdentifierProps { + class: string; + id: string; + '2legged'?: string; + 'kebab-case'?: boolean; + 'dotted.name'?: number; + 'with space'?: string; +} diff --git a/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml.success.json new file mode 100644 index 0000000..61505cf --- /dev/null +++ b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/response-204-no-content.openapi.yaml", + "specVersion": "3.0.3", + "title": "Empty", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/util.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml/rest/util.rest.generated.ts b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml/rest/util.rest.generated.ts new file mode 100644 index 0000000..da97c82 --- /dev/null +++ b/__test__/snapshots/generate-native/response-204-no-content.openapi.yaml/rest/util.rest.generated.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class UtilRest { + + readonly deletePing = requestFactory.zeroArg( + () => ({ + method: 'DELETE', + url: `/ping`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml.success.json new file mode 100644 index 0000000..a51c63b --- /dev/null +++ b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/response-blob-via-pdf.openapi.yaml", + "specVersion": "3.0.3", + "title": "PDF Response", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/report.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml/rest/report.rest.generated.ts b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml/rest/report.rest.generated.ts new file mode 100644 index 0000000..53f5742 --- /dev/null +++ b/__test__/snapshots/generate-native/response-blob-via-pdf.openapi.yaml/rest/report.rest.generated.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class ReportRest { + + readonly getReport = requestFactory.blob( + (request: GetReportParams) => { + const { id } = request; + return { + method: 'GET', + url: `/reports/${encodeURIComponent(id)}`, + }; + }, + ); +} + +export interface GetReportParams { + id: string; +} diff --git a/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml.success.json new file mode 100644 index 0000000..e6e413b --- /dev/null +++ b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/response-default-fallback.openapi.yaml", + "specVersion": "3.0.3", + "title": "Default", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 2 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/thing.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..360402a --- /dev/null +++ b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/model.generated.ts @@ -0,0 +1,7 @@ +export interface Err { + message?: string; +} + +export interface Thing { + id?: string; +} diff --git a/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/rest/thing.rest.generated.ts b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/rest/thing.rest.generated.ts new file mode 100644 index 0000000..9a786aa --- /dev/null +++ b/__test__/snapshots/generate-native/response-default-fallback.openapi.yaml/rest/thing.rest.generated.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { Thing } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class ThingRest { + + readonly getThing = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/thing`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml.success.json new file mode 100644 index 0000000..720f49b --- /dev/null +++ b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/response-octet-stream.openapi.yaml", + "specVersion": "3.0.3", + "title": "Blob", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/blob.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml/rest/blob.rest.generated.ts b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml/rest/blob.rest.generated.ts new file mode 100644 index 0000000..8c54c6b --- /dev/null +++ b/__test__/snapshots/generate-native/response-octet-stream.openapi.yaml/rest/blob.rest.generated.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class BlobRest { + + readonly getBlob = requestFactory.blob( + (request: GetBlobParams) => { + const { id } = request; + return { + method: 'GET', + url: `/blob/${encodeURIComponent(id)}`, + }; + }, + ); +} + +export interface GetBlobParams { + id: string; +} diff --git a/__test__/snapshots/generate-native/response-problem-json.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml.success.json new file mode 100644 index 0000000..e51e481 --- /dev/null +++ b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/response-problem-json.openapi.yaml", + "specVersion": "3.0.3", + "title": "Problem JSON", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/error.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/response-problem-json.openapi.yaml/rest/error.rest.generated.ts b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml/rest/error.rest.generated.ts new file mode 100644 index 0000000..43ffc8a --- /dev/null +++ b/__test__/snapshots/generate-native/response-problem-json.openapi.yaml/rest/error.rest.generated.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class ErrorRest { + + readonly getError = requestFactory( + (request: GetErrorParams) => { + const { id } = request; + return { + method: 'GET', + url: `/errors/${encodeURIComponent(id)}`, + }; + }, + ); +} + +export interface GetErrorParams { + id: string; +} diff --git a/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml.success.json b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml.success.json new file mode 100644 index 0000000..5bb420d --- /dev/null +++ b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml.success.json @@ -0,0 +1,22 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/response-text-via-text-plain.openapi.yaml", + "specVersion": "3.0.3", + "title": "Text Response", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 0 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/note.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml/rest/note.rest.generated.ts b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml/rest/note.rest.generated.ts new file mode 100644 index 0000000..b97d3a4 --- /dev/null +++ b/__test__/snapshots/generate-native/response-text-via-text-plain.openapi.yaml/rest/note.rest.generated.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; + +@Injectable({ + providedIn: 'root', +}) +export class NoteRest { + + readonly getNote = requestFactory.text( + (request: GetNoteParams) => { + const { id } = request; + return { + method: 'GET', + url: `/notes/${encodeURIComponent(id)}`, + }; + }, + ); +} + +export interface GetNoteParams { + id: string; +} diff --git a/__test__/snapshots/generate-native/security-schemes.openapi.yaml.success.json b/__test__/snapshots/generate-native/security-schemes.openapi.yaml.success.json new file mode 100644 index 0000000..e827335 --- /dev/null +++ b/__test__/snapshots/generate-native/security-schemes.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/security-schemes.openapi.yaml", + "specVersion": "3.0.3", + "title": "Security Schemes", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 2 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/pet.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/security-schemes.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..30d78df --- /dev/null +++ b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/model.generated.ts @@ -0,0 +1,5 @@ +export interface Pet { + id: string; +} + +export type PetList = Pet[]; diff --git a/__test__/snapshots/generate-native/security-schemes.openapi.yaml/rest/pet.rest.generated.ts b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/rest/pet.rest.generated.ts new file mode 100644 index 0000000..5488742 --- /dev/null +++ b/__test__/snapshots/generate-native/security-schemes.openapi.yaml/rest/pet.rest.generated.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { PetList } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); +} diff --git a/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml.success.json b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml.success.json new file mode 100644 index 0000000..9f35ab5 --- /dev/null +++ b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml.success.json @@ -0,0 +1,25 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/single-entry-composition.openapi.yaml", + "specVersion": "3.0.3", + "title": "Single Entry Composition", + "pathCount": 1, + "operationCount": 1, + "schemaCount": 4 + }, + "diagnostics": [], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + }, + { + "path": "rest/animal.rest.generated.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..3d93f75 --- /dev/null +++ b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/model.generated.ts @@ -0,0 +1,11 @@ +export interface AnimalBase { + id: string; + name: string; + nickname?: string | null; +} + +export type AnimalDraft = AnimalBase; + +export type AnimalView = AnimalBase; + +export type ContactPreference = string; diff --git a/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/rest/animal.rest.generated.ts b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/rest/animal.rest.generated.ts new file mode 100644 index 0000000..b99b28f --- /dev/null +++ b/__test__/snapshots/generate-native/single-entry-composition.openapi.yaml/rest/animal.rest.generated.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { requestFactory } from '../rest.util'; +import type { AnimalView } from '../model.generated'; + +@Injectable({ + providedIn: 'root', +}) +export class AnimalRest { + + readonly getAnimal = requestFactory( + (request: GetAnimalParams) => { + const { animalId } = request; + return { + method: 'GET', + url: `/animals/${encodeURIComponent(animalId)}`, + }; + }, + ); +} + +export interface GetAnimalParams { + animalId: string; +} diff --git a/__test__/snapshots/generate-native/static-template.json b/__test__/snapshots/generate-native/static-template.json new file mode 100644 index 0000000..fab4648 --- /dev/null +++ b/__test__/snapshots/generate-native/static-template.json @@ -0,0 +1,10 @@ +{ + "artifacts": [ + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/static-template/rest.model.ts b/__test__/snapshots/generate-native/static-template/rest.model.ts new file mode 100644 index 0000000..3167af7 --- /dev/null +++ b/__test__/snapshots/generate-native/static-template/rest.model.ts @@ -0,0 +1,78 @@ +import type { + HttpContext, + HttpHeaders, + HttpParams, + HttpResourceOptions, + HttpResourceRequest, +} from '@angular/common/http'; + +export type QueryParamValue = + | string + | number + | boolean + | ReadonlyArray; + +export interface CommonRequest extends Pick< + HttpResourceRequest, + 'body' | 'params' | 'headers' +> { + method: string; + url: string; + body?: unknown; + params?: HttpParams | Record; + headers?: HttpHeaders | Record; +} + +export interface WithDefault { + defaultValue: NoInfer; +} + +export interface WithParse { + parse: (raw: TRaw) => TResult; +} + +export type BaseHttpResourceOptions = Omit< + HttpResourceOptions, + 'parse' | 'defaultValue' +>; + +export type BaseHttpResourceOptionsWithParse = BaseHttpResourceOptions< + TResult, + TRaw +> & + WithParse; + +export type BaseHttpResourceOptionsWithDefault< + TResult, + TRaw = TResult, +> = BaseHttpResourceOptions & WithDefault; + +export type BaseHttpResourceOptionsWithDefaultAndParse = + BaseHttpResourceOptions & + WithParse & + WithDefault; + +export type HttpResourceOptionsUnion = + | BaseHttpResourceOptions + | BaseHttpResourceOptionsWithParse + | BaseHttpResourceOptionsWithDefault + | BaseHttpResourceOptionsWithDefaultAndParse; + +// Omits body/params/headers (generator supplies them) and responseType +// (fixed per requestFactory variant). Structurally compatible with +// HttpClient.request(method, url, options) so the runtime spread doesn't +// need a Parameters<…>[2] cast. +export type ObservableOptions = { + context?: HttpContext; + observe?: 'body' | 'response' | 'events'; + reportProgress?: boolean; + transferCache?: { includeHeaders?: string[] } | boolean; + withCredentials?: boolean; + keepalive?: boolean; + redirect?: RequestRedirect; + mode?: RequestMode; + credentials?: RequestCredentials; + priority?: RequestPriority; + cache?: RequestCache; + timeout?: number; +}; diff --git a/__test__/snapshots/generate-native/static-template/rest.util.ts b/__test__/snapshots/generate-native/static-template/rest.util.ts new file mode 100644 index 0000000..cff7e5d --- /dev/null +++ b/__test__/snapshots/generate-native/static-template/rest.util.ts @@ -0,0 +1,318 @@ +import { HttpClient, HttpParams, httpResource } from '@angular/common/http'; +import type { + HttpEvent, + HttpResourceOptions, + HttpResourceRef, + HttpResponse, +} from '@angular/common/http'; +import { + InjectionToken, + inject, + makeEnvironmentProviders, + type EnvironmentProviders, +} from '@angular/core'; +import type { Observable } from 'rxjs'; +import type { + BaseHttpResourceOptionsWithDefault, + BaseHttpResourceOptionsWithDefaultAndParse, + BaseHttpResourceOptionsWithParse, + CommonRequest, + HttpResourceOptionsUnion, + ObservableOptions, + QueryParamValue, +} from './rest.model'; + +type QueryParamRecord = Record; +type ResponseType = 'blob' | 'text' | 'arraybuffer'; + +export const OPENAPI_NG_BASE_PATH = new InjectionToken('OPENAPI_NG_BASE_PATH'); + +export function provideOpenapiNg(config: { basePath: string }): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: OPENAPI_NG_BASE_PATH, useValue: config.basePath }, + ]); +} + +export function httpParams(params: QueryParamRecord): HttpParams { + let resolved = new HttpParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + if (Array.isArray(value)) { + for (const item of value) { + resolved = resolved.append(key, String(item)); + } + } else { + resolved = resolved.set(key, String(value)); + } + } + } + return resolved; +} + +export interface RequestFnValue { + request(request: Request): CommonRequest; + observable( + request: Request, + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + request: Request, + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(request: Request, options?: ObservableOptions): Observable; + resource( + reactiveReq: () => Request | undefined, + options: BaseHttpResourceOptionsWithDefault, + ): HttpResourceRef; + resource( + reactiveReq: () => Request | undefined, + options: BaseHttpResourceOptionsWithDefaultAndParse, + ): HttpResourceRef; + resource( + reactiveReq: () => Request | undefined, + options?: HttpResourceOptionsUnion, + ): HttpResourceRef; + resource( + reactiveReq: () => Request | undefined, + options: BaseHttpResourceOptionsWithParse, + ): HttpResourceRef; +} + +export interface RequestFnVoid { + request(request: Request): CommonRequest; + observable( + request: Request, + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + request: Request, + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(request: Request, options?: ObservableOptions): Observable; + resource( + reactiveReq: () => Request | undefined, + options?: HttpResourceOptionsUnion, + ): HttpResourceRef; +} + +export type RequestFn = [Response] extends [void] + ? RequestFnVoid + : RequestFnValue; + +export interface ZeroArgRequestFnValue { + request(): CommonRequest; + observable( + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(options?: ObservableOptions): Observable; + resource( + options: BaseHttpResourceOptionsWithDefault, + ): HttpResourceRef; + resource( + options: BaseHttpResourceOptionsWithDefaultAndParse, + ): HttpResourceRef; + resource( + options?: HttpResourceOptionsUnion, + ): HttpResourceRef; + resource( + options: BaseHttpResourceOptionsWithParse, + ): HttpResourceRef; +} + +export interface ZeroArgRequestFnVoid { + request(): CommonRequest; + observable( + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(options?: ObservableOptions): Observable; + resource(options?: HttpResourceOptionsUnion): HttpResourceRef; +} + +export type ZeroArgRequestFn = [Response] extends [void] + ? ZeroArgRequestFnVoid + : ZeroArgRequestFnValue; + +function joinBasePath(base: string, url: string): string { + // Absolute URLs (https://…, etc.) bypass the configured basePath. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return url; + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; + const normalizedUrl = url.startsWith('/') ? url : `/${url}`; + return normalizedBase + normalizedUrl; +} + +function withBasePath( + reqFn: (...args: TArgs) => CommonRequest, +): (...args: TArgs) => CommonRequest { + const basePath = inject(OPENAPI_NG_BASE_PATH, { optional: true }); + if (!basePath) return reqFn; + return (...args: TArgs) => { + const common = reqFn(...args); + return { ...common, url: joinBasePath(basePath, common.url) }; + }; +} + +type ObserveFn = ( + request: CommonRequest, + options?: ObservableOptions, +) => Observable; + +function makeObserveFn( + http: HttpClient, + responseType?: ResponseType, +): ObserveFn { + return (request, options) => { + const merged = { + ...options, + body: request.body, + headers: request.headers, + params: request.params, + ...(responseType ? { responseType } : {}), + }; + return http.request(request.method, request.url, merged) as Observable; + }; +} + +function makeRequestFn( + reqFn: (req: Request) => CommonRequest, + observe: ObserveFn, + resourceImpl: ( + request: () => CommonRequest | undefined, + options?: HttpResourceOptions, + ) => HttpResourceRef, +): RequestFn { + const wrappedReqFn = withBasePath(reqFn); + return { + request: (req: Request): CommonRequest => wrappedReqFn(req), + observable: (req: Request, options?: ObservableOptions): Observable => + observe(wrappedReqFn(req), options), + resource: ( + reactiveReq: () => Request | undefined, + options?: HttpResourceOptionsUnion, + ): HttpResourceRef => + resourceImpl(() => { + const request = reactiveReq(); + return request === undefined ? undefined : wrappedReqFn(request); + }, options), + } as RequestFn; +} + +function makeZeroArgRequestFn( + reqFn: () => CommonRequest, + observe: ObserveFn, + resourceImpl: ( + request: () => CommonRequest | undefined, + options?: HttpResourceOptions, + ) => HttpResourceRef, +): ZeroArgRequestFn { + const wrappedReqFn = withBasePath(reqFn); + return { + request: (): CommonRequest => wrappedReqFn(), + observable: (options?: ObservableOptions): Observable => + observe(wrappedReqFn(), options), + resource: ( + options?: HttpResourceOptionsUnion, + ): HttpResourceRef => + resourceImpl(() => wrappedReqFn(), options), + } as ZeroArgRequestFn; +} + +function makeJsonRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http), + (request, options) => httpResource(request, options), + ); +} + +function makeJsonZeroArg( + reqFn: () => CommonRequest, +): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http), + (request, options) => httpResource(request, options), + ); +} + +function makeBlobRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http, 'blob'), + (request, options) => httpResource.blob(request, options), + ); +} + +function makeBlobZeroArg(reqFn: () => CommonRequest): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http, 'blob'), + (request, options) => httpResource.blob(request, options), + ); +} + +function makeTextRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http, 'text'), + (request, options) => httpResource.text(request, options), + ); +} + +function makeTextZeroArg(reqFn: () => CommonRequest): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http, 'text'), + (request, options) => httpResource.text(request, options), + ); +} + +function makeArrayBufferRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http, 'arraybuffer'), + (request, options) => httpResource.arrayBuffer(request, options), + ); +} + +function makeArrayBufferZeroArg( + reqFn: () => CommonRequest, +): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http, 'arraybuffer'), + (request, options) => httpResource.arrayBuffer(request, options), + ); +} + +export const requestFactory = Object.assign(makeJsonRequestFn, { + blob: makeBlobRequestFn, + text: makeTextRequestFn, + arrayBuffer: makeArrayBufferRequestFn, + zeroArg: Object.assign(makeJsonZeroArg, { + blob: makeBlobZeroArg, + text: makeTextZeroArg, + arrayBuffer: makeArrayBufferZeroArg, + }), +}); diff --git a/__test__/snapshots/generate-native/string-formats.openapi.yaml.success.json b/__test__/snapshots/generate-native/string-formats.openapi.yaml.success.json new file mode 100644 index 0000000..1dd9505 --- /dev/null +++ b/__test__/snapshots/generate-native/string-formats.openapi.yaml.success.json @@ -0,0 +1,58 @@ +{ + "summary": { + "normalizedSourcePath": "test/fixtures/string-formats.openapi.yaml", + "specVersion": "3.0.3", + "title": "String Formats", + "pathCount": 0, + "operationCount": 0, + "schemaCount": 1 + }, + "diagnostics": [ + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema Contact.email declares format 'email', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/string-formats.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema Contact.identifier declares format 'uuid', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/string-formats.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema Contact.homepage declares format 'uri', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/string-formats.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema Contact.birthday declares format 'date', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/string-formats.openapi.yaml", + "subcode": "format-dropped" + }, + { + "code": "E_UNSUPPORTED_SEMANTIC", + "severity": "warning", + "message": "schema Contact.lastSeen declares format 'date-time', which is currently dropped — the generator emits the base type without format-specific narrowing.", + "path": "test/fixtures/string-formats.openapi.yaml", + "subcode": "format-dropped" + } + ], + "artifacts": [ + { + "path": "model.generated.ts" + }, + { + "path": "rest.model.ts" + }, + { + "path": "rest.util.ts" + } + ] +} diff --git a/__test__/snapshots/generate-native/string-formats.openapi.yaml/model.generated.ts b/__test__/snapshots/generate-native/string-formats.openapi.yaml/model.generated.ts new file mode 100644 index 0000000..917cc4c --- /dev/null +++ b/__test__/snapshots/generate-native/string-formats.openapi.yaml/model.generated.ts @@ -0,0 +1,8 @@ +export interface Contact { + email: string; + identifier: string; + homepage: string; + birthday: string; + lastSeen: string; + notes?: string; +} diff --git a/__test__/snapshots/generate-native/unbalanced-path-template.openapi.yaml.failure.json b/__test__/snapshots/generate-native/unbalanced-path-template.openapi.yaml.failure.json new file mode 100644 index 0000000..dba42ed --- /dev/null +++ b/__test__/snapshots/generate-native/unbalanced-path-template.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: path template /pets/{id has an unbalanced '{' with no matching '}'.. See the supported subset documented in README.md ('Out of Scope' section).", + "path": "test/fixtures/unbalanced-path-template.openapi.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/unsupported-root.yaml.failure.json b/__test__/snapshots/generate-native/unsupported-root.yaml.failure.json new file mode 100644 index 0000000..a0ead6f --- /dev/null +++ b/__test__/snapshots/generate-native/unsupported-root.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_INPUT_INVALID", + "message": "Failed to decode OpenAPI input as YAML: components.schemas: invalid type: sequence, expected a map at line 8 column 5", + "path": "test/fixtures/unsupported-root.yaml", + "warnings": [] +} diff --git a/__test__/snapshots/generate-native/unsupported-semantic.openapi.yaml.failure.json b/__test__/snapshots/generate-native/unsupported-semantic.openapi.yaml.failure.json new file mode 100644 index 0000000..a9fe33f --- /dev/null +++ b/__test__/snapshots/generate-native/unsupported-semantic.openapi.yaml.failure.json @@ -0,0 +1,6 @@ +{ + "code": "E_UNSUPPORTED_SEMANTIC", + "message": "Unsupported OpenAPI semantic shape: schema EventEnvelope composition member 2.data must define additionalProperties as a schema object.", + "path": "test/fixtures/unsupported-semantic.openapi.yaml", + "warnings": [] +} diff --git a/__test__/tsconfig.json b/__test__/tsconfig.json new file mode 100644 index 0000000..5f6ab46 --- /dev/null +++ b/__test__/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "es2022", + "moduleResolution": "node", + "outDir": "lib", + "types": ["node"] + }, + "include": ["."], + "exclude": ["lib"] +} diff --git a/benchmark/bench-budgets.json b/benchmark/bench-budgets.json new file mode 100644 index 0000000..7c5e87f --- /dev/null +++ b/benchmark/bench-budgets.json @@ -0,0 +1,10 @@ +{ + "$comment": "Per-bench mean-latency budgets (milliseconds). The runner fails when a task's measured mean exceeds budget * 1.25 — the 25% headroom absorbs noise / slower CI runners while still catching real regressions. Re-baseline by running `pnpm bench` on the reference machine and rounding the reported mean upward; the goal is a budget tight enough to catch 1.5× regressions but loose enough that ordinary cross-machine variance doesn't flake. Tasks not listed here are exempt (no budget) — add an entry when a task becomes load-bearing.", + "budgets": { + "generate (petstore-minimal, yaml)": 1.0, + "generate (petstore-rich, yaml)": 1.2, + "generate (petstore-rich, json)": 1.2, + "generate (bench-large, yaml)": 6.0, + "generate (bench-multi-tag, yaml)": 9.0 + } +} diff --git a/benchmark/bench.ts b/benchmark/bench.ts new file mode 100644 index 0000000..a9f6f96 --- /dev/null +++ b/benchmark/bench.ts @@ -0,0 +1,196 @@ +import { Bench } from 'tinybench'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import openapiNg from '../lib/index.js'; + +const { generate } = openapiNg; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const outputPath = mkdtempSync(join(tmpdir(), 'openapi-ng-bench-')); + +/** + * Synthesise a stress spec with `count` resources, each carrying its own + * Resource/ResourceList/ResourceStatus triple (3*count schemas) and a + * GET-list + POST-create pair (2*count operations). Written to a temp + * dir and pointed at by the runner; not committed because the file + * would be ~10× the size of bench-large.yaml without exercising any + * code path that bench-large doesn't already cover. + * + * Regression smoke test only: kept under bench-budgets.json with + * generous headroom (the goal is "still finishes", not a tight perf + * gate). bench-large remains the load-bearing scale benchmark. + */ +function writeStressSpec(resourceCount: number): string { + const lines: string[] = [ + 'openapi: 3.0.3', + 'info:', + ' title: Bench Stress', + ' version: 1.0.0', + 'paths:', + ]; + for (let i = 1; i <= resourceCount; i++) { + lines.push(` /resource${i}:`); + lines.push(' get:'); + lines.push(` operationId: getResource${i}`); + lines.push(` tags: [Resource${i}]`); + lines.push(' responses:'); + lines.push(` '200':`); + lines.push(` description: ok`); + lines.push(' content:'); + lines.push(' application/json:'); + lines.push(' schema:'); + lines.push(` $ref: '#/components/schemas/Resource${i}List'`); + lines.push(' post:'); + lines.push(` operationId: createResource${i}`); + lines.push(` tags: [Resource${i}]`); + lines.push(' requestBody:'); + lines.push(' required: true'); + lines.push(' content:'); + lines.push(' application/json:'); + lines.push(' schema:'); + lines.push(` $ref: '#/components/schemas/Resource${i}'`); + lines.push(' responses:'); + lines.push(` '200':`); + lines.push(` description: ok`); + lines.push(' content:'); + lines.push(' application/json:'); + lines.push(' schema:'); + lines.push(` $ref: '#/components/schemas/Resource${i}'`); + } + lines.push('components:'); + lines.push(' schemas:'); + for (let i = 1; i <= resourceCount; i++) { + lines.push(` Resource${i}:`); + lines.push(' type: object'); + lines.push(' required: [id, status]'); + lines.push(' properties:'); + lines.push(' id:'); + lines.push(' type: string'); + lines.push(' status:'); + lines.push(` $ref: '#/components/schemas/Resource${i}Status'`); + lines.push(` Resource${i}Status:`); + lines.push(' type: string'); + lines.push(' enum: [active, inactive]'); + lines.push(` Resource${i}List:`); + lines.push(' type: object'); + lines.push(' required: [items]'); + lines.push(' properties:'); + lines.push(' items:'); + lines.push(' type: array'); + lines.push(' items:'); + lines.push(` $ref: '#/components/schemas/Resource${i}'`); + } + const target = join(outputPath, 'bench-stress.openapi.yaml'); + writeFileSync(target, lines.join('\n') + '\n', 'utf8'); + return target; +} + +// 250 resources × (3 schemas + 2 ops) = 750 schemas + 500 ops. Chosen +// to land squarely above bench-large (90 schemas / 60 ops) without +// committing a 20k-line YAML. Tweakable via the constant below. +const STRESS_RESOURCE_COUNT = 250; +const stressSpecPath = writeStressSpec(STRESS_RESOURCE_COUNT); + +const bench = new Bench({ warmupIterations: 100 }); + +bench.add('generate (petstore-rich, yaml)', async () => { + await generate({ + inputPath: 'test/fixtures/petstore-rich.openapi.yaml', + outputPath, + emit: ['models', 'angular'], + }); +}); + +bench.add('generate (petstore-rich, json)', async () => { + await generate({ + inputPath: 'test/fixtures/petstore-rich.openapi.json', + outputPath, + emit: ['models', 'angular'], + }); +}); + +bench.add('generate (petstore-minimal, yaml)', async () => { + await generate({ + inputPath: 'test/fixtures/petstore-minimal.openapi.yaml', + outputPath, + emit: ['models', 'angular'], + }); +}); + +// E11: large-spec benchmark — 30 paths × 2 ops = 60 operations and 90 schemas +// (30 entities × {Resource, ResourceStatus, ResourceList}), grouped under +// 6 tags (Resource1..Resource6, 10 ops each). Synthetic but shaped like a +// real REST API; useful for catching phase-level regressions +// (normalize/lower/emit) that don't surface in petstore-sized inputs. +bench.add('generate (bench-large, yaml)', async () => { + await generate({ + inputPath: 'test/fixtures/bench-large.openapi.yaml', + outputPath, + emit: ['models', 'angular'], + }); +}); + +// bench-multi-tag exercises by-tag grouping at a different scale: 5 tags × +// 20 ops each (100 ops total). Complements bench-large by stressing +// operation_grouper's per-group emission path with denser group fanout. +bench.add('generate (bench-multi-tag, yaml)', async () => { + await generate({ + inputPath: 'test/fixtures/bench-multi-tag.openapi.yaml', + outputPath, + emit: ['models', 'angular'], + }); +}); + +// bench-stress: synthesised at startup, ~750 schemas + 500 operations. +// Pure regression smoke test for perf — exercises the same code paths +// as bench-large but at ~10× the schema count and ~8× the operation +// count. Budget intentionally generous (the goal is "still finishes", +// not a tight per-ns gate); bench-large remains the load-bearing +// scale benchmark for tight regression detection. +bench.add('generate (bench-stress, yaml)', async () => { + await generate({ + inputPath: stressSpecPath, + outputPath, + emit: ['models', 'angular'], + }); +}); + +await bench.run(); + +console.table(bench.table()); + +// Perf budgets live in bench-budgets.json (mean latency in milliseconds); +// the runner fails when a task's measured mean exceeds budget × 1.25. The +// 25% headroom absorbs noise and slower CI runners while still catching +// real regressions. Tasks without a budget entry are exempt. +// `tinybench`'s `result.latency.mean` is already in milliseconds; the +// table column header reads "ns" because the display layer multiplies by +// 1e6 before formatting. +const BUDGET_HEADROOM = 1.25; +const budgetsPath = join(__dirname, 'bench-budgets.json'); +const { budgets }: { budgets: Record } = JSON.parse( + readFileSync(budgetsPath, 'utf8'), +); + +const regressions: string[] = []; +for (const task of bench.tasks) { + const budgetMs = budgets[task.name]; + if (budgetMs === undefined) continue; + const result = task.result; + if (!result) continue; + const meanMs = result.latency.mean; + const limitMs = budgetMs * BUDGET_HEADROOM; + if (meanMs > limitMs) { + regressions.push( + ` - ${task.name}: mean ${meanMs.toFixed(3)}ms exceeds budget ${budgetMs}ms × ${BUDGET_HEADROOM} (${limitMs.toFixed(3)}ms)`, + ); + } +} +if (regressions.length > 0) { + console.error(`Perf regression detected:\n${regressions.join('\n')}`); + process.exit(1); +} diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json new file mode 100644 index 0000000..659905b --- /dev/null +++ b/benchmark/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib" + }, + "include": ["."], + "exclude": ["lib"] +} diff --git a/bin/.bg-shell/manifest.json b/bin/.bg-shell/manifest.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/bin/.bg-shell/manifest.json @@ -0,0 +1 @@ +[] diff --git a/bin/lib/parse.js b/bin/lib/parse.js new file mode 100644 index 0000000..f20d147 --- /dev/null +++ b/bin/lib/parse.js @@ -0,0 +1,451 @@ +// CLI argument and config parsing helpers extracted from openapi-ng.js. +// Kept as a separate module so they are unit-testable without spawning +// a subprocess and without coupling to the runtime/output surface. +// +// The exported records mirror the `MappedType` shape on the NAPI +// boundary (`schema/import/type/alias`) one-to-one — the CLI does not +// translate between naming worlds. + +const fs = require('node:fs'); +const path = require('node:path'); + +const VALID_EMIT_TARGETS = Object.freeze(new Set(['models', 'angular'])); +const DEFAULT_EMIT = Object.freeze(['models', 'angular']); + +const VALID_INIT_FORMATS = Object.freeze(new Set(['yaml', 'json', 'ts', 'js'])); + +// Validate that argv[i + 1] is a real value, not the next flag or end-of-args. +// Without this, `--config --input spec.yaml` silently consumes `--input` as +// the config path, leaving the user staring at a config-not-found error +// without ever seeing their `--input` argument honoured. Treat any token +// starting with `-` (long `--foo` or short `-f`) as a flag, never a value. +function requireValue(argv, i, flagName) { + const value = argv[i + 1]; + if ( + value === undefined || + value === null || + (typeof value === 'string' && value.startsWith('-')) + ) { + throw new Error(`${flagName} requires a value`); + } + return value; +} + +// Normalize one user-supplied emit list (CLI comma-string or YAML +// array) into a deduped array of recognised targets. Unknown entries +// fail fast with a config-file hint. +function normalizeEmit(value) { + if (value === null || value === undefined) return null; + + let items; + if (Array.isArray(value)) { + items = value.map(v => String(v).trim()).filter(Boolean); + } else if (typeof value === 'string') { + items = value + .split(',') + .map(s => s.trim()) + .filter(Boolean); + } else { + throw new Error( + `Invalid emit value: expected an array (YAML 'emit: [models, angular]') ` + + `or comma-separated string ('--emit models,angular'); got ${typeof value}.`, + ); + } + + for (const item of items) { + if (!VALID_EMIT_TARGETS.has(item)) { + throw new Error(`Unknown emit target: '${item}'. Allowed: 'models', 'angular'.`); + } + } + + return Array.from(new Set(items)); +} + +function parseMappedType(value) { + const source = String(value); + // Reject up front: importPath segments may not contain ':' under the + // colon-delimited CLI surface. Use mappedTypes: in the YAML/JSON config + // file when import paths contain colons (e.g. Windows-style absolute + // paths like C:\foo, or :: namespace separators). + const parts = source.split(':'); + if (parts.length < 3 || parts.length > 4 || parts.some(part => part.length === 0)) { + throw new Error( + `Invalid --mapped-type value: ${value}. Expected . ` + + `For import paths containing ':' (e.g., Windows absolute paths), use the mappedTypes: ` + + `entry in your .openapi-ng.yaml / .openapi-ng.json config file instead.`, + ); + } + + const [schema, importPath, typeName, alias] = parts; + + return { + schema, + import: importPath, + type: typeName, + alias, + }; +} + +// File names to probe at each level of the directory walk. Order matters: +// the first existing file wins. Modern config-style names (no dotfile +// prefix, matches vite/vitest/jest convention) rank above the legacy +// dotfile names so a project mid-migration prefers the richer JS/TS +// config when both are present. +const CONFIG_FILENAMES = Object.freeze([ + 'openapi-ng.config.ts', + 'openapi-ng.config.mts', + 'openapi-ng.config.cts', + 'openapi-ng.config.mjs', + 'openapi-ng.config.js', + 'openapi-ng.config.cjs', + '.openapi-ng.yaml', + '.openapi-ng.json', +]); + +function discoverConfigPath(startDir) { + let dir = path.resolve(startDir); + let prev; + + while (prev !== dir) { + for (const name of CONFIG_FILENAMES) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) return candidate; + } + prev = dir; + dir = path.dirname(dir); + } + + return null; +} + +const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']); + +async function loadConfigFile(configPath) { + const ext = path.extname(configPath).toLowerCase(); + + // ── JS/TS branch: dynamic import + default-export validation ─────────── + if (JS_EXTENSIONS.has(ext)) { + const { pathToFileURL } = require('node:url'); + const absPath = path.resolve(configPath); + + // Existence check up front. Dynamic import() raises an ERR_MODULE_NOT_FOUND + // that includes the resolved URL — wrap it in our shape so the CLI + // formatter prints the same "Config file not found:" line YAML/JSON + // produces today. + if (!fs.existsSync(absPath)) { + const e = new Error(`Config file not found: ${configPath}`); + e.code = 'E_INPUT_INVALID'; + throw e; + } + + let mod; + try { + mod = await import(pathToFileURL(absPath).href); + } catch (err) { + // TypeScript file types (.ts/.mts/.cts) on Node < 22.6 produce + // ERR_UNKNOWN_FILE_EXTENSION because the loader has no idea what + // to do with TypeScript. Map it to a version-aware hint instead + // of the generic "Failed to load" wrap so users know the fix + // (upgrade Node, switch to .js, or pass --experimental-strip-types). + const isTs = ext === '.ts' || ext === '.mts' || ext === '.cts'; + if (isTs && err?.code === 'ERR_UNKNOWN_FILE_EXTENSION') { + const e = new Error( + `TypeScript config files require Node 22.6+ with --experimental-strip-types, ` + + `or Node 23.6+ (flag enabled by default). ` + + `Alternatively, use a .js/.mjs config.`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } + const e = new Error( + `Failed to load config file ${configPath}: ${err?.message ?? err}`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } + + if (!('default' in mod) || mod.default === undefined) { + const e = new Error( + `Config file ${configPath} has no default export. ` + + `Use \`export default { ... }\` or \`module.exports = { ... }\`.`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } + + let value = mod.default; + // Allow `export default async () => ({...})` and `export default () => ({...})`. + if (typeof value === 'function') { + value = await value(); + } + + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + const e = new Error( + `Config file ${configPath} default export must be an object or function returning one; ` + + `got ${value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value}.`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } + + return value; + } + + // ── YAML/JSON branch (unchanged from before, factored out) ──────────── + // Both ENOENT (missing file) and YAML/JSON syntax errors are + // user-input problems, not CLI option-parsing problems. Tag them + // with E_INPUT_INVALID so formatParseFailure renders a stable code + // for downstream tooling, and strip the raw POSIX message so we + // don't leak filesystem internals to the terminal. + let contents; + try { + contents = fs.readFileSync(configPath, 'utf8'); + } catch (err) { + if (err && err.code === 'ENOENT') { + const e = new Error(`Config file not found: ${configPath}`); + e.code = 'E_INPUT_INVALID'; + throw e; + } + const e = new Error( + `Failed to read config file ${configPath}: ${err?.message ?? err}`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } + + try { + if (ext === '.json') { + return JSON.parse(contents); + } + + const YAML = require('yaml'); + return YAML.parse(contents) ?? {}; + } catch (err) { + const e = new Error( + `Failed to parse config file ${configPath}: ${err?.message ?? err}`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } +} + +function normalizeMappedTypes(items) { + if (!Array.isArray(items)) return null; + return items.map(item => ({ + schema: item.schema, + import: item.import, + type: item.type, + alias: item.alias, + })); +} + +function normalizeResponseTypeMapping(items) { + if (!Array.isArray(items)) return null; + return items.map(item => ({ + contentType: item.contentType, + responseType: item.responseType, + })); +} + +function normalizeNamingFromFile(naming) { + if (naming === undefined || naming === null) return null; + if (typeof naming !== 'object' || Array.isArray(naming)) { + const e = new Error( + `Invalid naming config: expected an object with optional 'methodName' and 'group' keys.`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } + // `parse` must be a JavaScript RegExp. JS/TS configs deliver one + // directly; YAML/JSON cannot encode RegExp, so any `parse:` value + // from those formats is a string/object/etc. and fails this check. + // This keeps the "no parse in YAML/JSON" safety property without + // tracking the source format through the call chain. + for (const key of ['methodName', 'group']) { + const value = naming[key]; + if (value === undefined) continue; + const items = Array.isArray(value) ? value : [value]; + for (const item of items) { + if ( + item && + typeof item === 'object' && + item.parse !== undefined && + !(item.parse instanceof RegExp) + ) { + const e = new Error( + `naming.${key}: 'parse' must be a JavaScript RegExp. ` + + `YAML/JSON configs cannot encode RegExp — use an openapi-ng.config.ts ` + + `(or .js/.mjs) file when you need 'parse' rules.`, + ); + e.code = 'E_INPUT_INVALID'; + throw e; + } + } + } + return naming; +} + +function mergeConfig(fileConfig, cliFlags) { + const merged = {}; + + merged.inputPath = cliFlags.inputPath ?? fileConfig.input ?? null; + merged.outputPath = cliFlags.outputPath ?? fileConfig.output ?? null; + merged.verbose = cliFlags.verbose ?? false; + + const cliEmit = normalizeEmit(cliFlags.emit); + const fileEmit = normalizeEmit(fileConfig.emit); + merged.emit = cliEmit ?? fileEmit ?? [...DEFAULT_EMIT]; + + const fileMappedTypes = normalizeMappedTypes(fileConfig.mappedTypes); + merged.mappedTypes = cliFlags.mappedTypes ?? fileMappedTypes ?? null; + + merged.responseTypeMapping = normalizeResponseTypeMapping( + fileConfig.responseTypeMapping, + ); + + merged.naming = cliFlags.naming ?? normalizeNamingFromFile(fileConfig.naming); + + return merged; +} + +function parseArgs(argv) { + let configPath = null; + + // `--version` / `-v` is a global flag: recognised anywhere in argv so + // users can type `openapi-ng --version`, `openapi-ng generate --version`, + // or `openapi-ng -v` and always get the version. Short-circuits before + // any other parsing so it cannot trip on a half-finished command line. + if (argv.some(token => token === '--version' || token === '-v')) { + return { kind: 'version' }; + } + + // Extract global --config/-c before command parsing + const filteredArgv = []; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (token === '--config' || token === '-c') { + configPath = requireValue(argv, i, '--config'); + i += 1; + continue; + } + filteredArgv.push(token); + } + + const [command, ...rest] = filteredArgv; + + // Distinguish bare `openapi-ng` (no command + no global help flag) from + // explicit `--help`/`-h`. CI scripts like `openapi-ng generate ... && + // next-step` would silently run `next-step` if the `generate` argv got + // eaten; bare invocation is a usage error and must exit non-zero. We + // still print help so the user can recover — only the exit code differs. + // `explicit: false` is the signal for the caller to set `process.exitCode = 2`. + if (!command) { + return { kind: 'help', subcommand: null, explicit: false }; + } + if (command === '--help' || command === '-h') { + return { kind: 'help', subcommand: null, explicit: true }; + } + + if (command === 'init') { + let format = 'yaml'; + for (let i = 0; i < rest.length; i += 1) { + const token = rest[i]; + if (token === '--help' || token === '-h') { + return { kind: 'help', subcommand: 'init', explicit: true }; + } + if (token === '--format') { + const value = requireValue(rest, i, '--format'); + if (!VALID_INIT_FORMATS.has(value)) { + throw new Error( + `Unknown --format value: '${value}'. Allowed: 'yaml', 'json', 'ts', 'js'.`, + ); + } + format = value; + i += 1; + continue; + } + throw new Error(`Unsupported argument: ${token}`); + } + return { kind: 'init', format }; + } + + if (command !== 'generate') { + throw new Error(`Unsupported command: ${command}`); + } + + let inputPath = null; + let outputPath = null; + let verbose = null; + const emitTokens = []; + const mappedTypes = []; + + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + // Per-subcommand help short-circuit. Recognised anywhere in the + // argument list so users can append `--help` to a half-finished + // command without erasing the rest first. + if (token === '--help' || token === '-h') { + return { kind: 'help', subcommand: 'generate', explicit: true }; + } + + if (token === '--input' || token === '-i') { + inputPath = requireValue(rest, index, '--input'); + index += 1; + continue; + } + + if (token === '--output' || token === '-o') { + outputPath = requireValue(rest, index, '--output'); + index += 1; + continue; + } + + if (token === '--verbose') { + verbose = true; + continue; + } + + if (token === '--emit') { + const value = requireValue(rest, index, '--emit'); + emitTokens.push(value); + index += 1; + continue; + } + + if (token === '--mapped-type') { + mappedTypes.push(parseMappedType(requireValue(rest, index, '--mapped-type'))); + index += 1; + continue; + } + + throw new Error(`Unsupported argument: ${token}`); + } + + // Normalise eagerly so unknown emit targets fail at parse time rather + // than at validate time inside the Rust binding. + const emit = emitTokens.length > 0 ? normalizeEmit(emitTokens.join(',')) : null; + + return { + kind: 'generate', + inputPath, + outputPath, + verbose, + emit, + mappedTypes: mappedTypes.length > 0 ? mappedTypes : null, + configPath, + }; +} + +module.exports = { + parseMappedType, + discoverConfigPath, + loadConfigFile, + normalizeMappedTypes, + normalizeResponseTypeMapping, + normalizeNamingFromFile, + normalizeEmit, + mergeConfig, + parseArgs, + DEFAULT_EMIT, + CONFIG_FILENAMES, +}; diff --git a/bin/openapi-ng.js b/bin/openapi-ng.js new file mode 100644 index 0000000..669a292 --- /dev/null +++ b/bin/openapi-ng.js @@ -0,0 +1,378 @@ +#!/usr/bin/env node + +// Resolve through the wrapper so caught errors are `GenerateError` +// instances (the CLI formatter doesn't depend on `instanceof`, but +// consumers debugging via `node --inspect` see a consistent shape). +// NOTE: do NOT require('../lib/index.js') at module top — that would load +// the native binding on every invocation, including --help and --version. +// Use loadLibrary() inside the generate handler instead. +function loadLibrary() { + return require('../lib/index.js'); +} + +const fs = require('node:fs'); +const path = require('node:path'); +const { + CONFIG_FILENAMES, + discoverConfigPath, + loadConfigFile, + mergeConfig, + parseArgs, +} = require('./lib/parse.js'); + +// Minimal ANSI styler — emit colour only when stdout is a TTY and NO_COLOR is unset +const USE_COLOR = process.stdout.isTTY === true && !process.env.NO_COLOR; +const wrap = code => (USE_COLOR ? s => `\x1b[${code}m${s}\x1b[0m` : s => String(s)); +const c = { + bold: wrap(1), + dim: wrap(2), + red: wrap(31), + green: wrap(32), + yellow: wrap(33), + cyan: wrap(36), +}; + +function printUsage() { + process.stdout.write( + [ + `${c.bold('Usage:')}`, + ` openapi-ng generate [--input ] [--output ] [--verbose]`, + ` [--emit ] [--config ]`, + ` [--mapped-type ]`, + ` openapi-ng init [--format yaml|json|ts|js]`, + '', + `${c.bold('Global flags:')}`, + ` --config, -c Path to config file (default: auto-discover openapi-ng.config.{ts,mts,cts,mjs,js,cjs} or .openapi-ng.yaml/.json)`, + ` --help, -h Print this help message (works on any subcommand)`, + ` --version, -v Print the openapi-ng package version and exit`, + '', + `${c.bold('Emit targets:')}`, + ` --emit models,angular Comma-separated list (repeatable). Default: 'models,angular'.`, + ` 'angular' depends on 'models'; it is auto-included.`, + '', + `${c.bold('Supported inputs:')}`, + ' - Local OpenAPI 3.x JSON or YAML files within the current subset.', + ' - https:// URLs pointing to a JSON or YAML OpenAPI 3.x document.', + '', + `${c.bold('Examples:')}`, + ` openapi-ng init [--format yaml|json|ts|js] Create a starter config file`, + ` openapi-ng generate -i spec.yaml -o ./src/generated`, + ` openapi-ng generate --emit models --output ./tmp`, + ].join('\n'), + ); + process.stdout.write('\n'); +} + +function printGenerateUsage() { + process.stdout.write( + [ + `${c.bold('Usage:')}`, + ` openapi-ng generate [flags]`, + '', + `${c.bold('Flags:')}`, + ` --input, -i Path to an OpenAPI spec file, or an https:// URL.`, + ` --output, -o Output directory for generated files.`, + ` Omit to run in-memory (no files written).`, + ` --emit Comma-separated list (repeatable). Default: 'models,angular'.`, + ` Valid: 'models', 'angular'.`, + ` 'angular' depends on 'models'; it is auto-included.`, + ` --mapped-type Map schema to imported type from path ,`, + ` optionally renamed to . Repeatable.`, + ` --verbose Include warnings in the success summary.`, + ` --config, -c Path to config file (overrides auto-discovery).`, + ` --help, -h Print this help.`, + ` --version, -v Print the openapi-ng package version and exit.`, + '', + `${c.bold('Examples:')}`, + ` openapi-ng generate -i spec.yaml -o ./src/generated`, + ` openapi-ng generate --input spec.yaml --output ./tmp --verbose`, + ` openapi-ng generate --config my-openapi-ng.yaml`, + ].join('\n'), + ); + process.stdout.write('\n'); +} + +function printInitUsage() { + process.stdout.write( + [ + `${c.bold('Usage:')}`, + ` openapi-ng init [--format ]`, + '', + `${c.bold('Flags:')}`, + ` --format yaml (default) | json | ts | js`, + '', + `Writes a starter config file in the current directory.`, + `Aborts if a same-name config file already exists.`, + ].join('\n'), + ); + process.stdout.write('\n'); +} + +function formatSuccess(result, verbose) { + const { summary, artifacts, diagnostics } = result; + const count = artifacts.length; + const lines = [ + `${c.bold(c.green('✓'))} Generated ${c.bold(count)} file${count !== 1 ? 's' : ''} from ${c.bold(summary.title)} ${c.dim(`(${summary.specVersion})`)}`, + ` ${c.dim(`${summary.pathCount} path${summary.pathCount !== 1 ? 's' : ''} · ${summary.operationCount} operation${summary.operationCount !== 1 ? 's' : ''} · ${summary.schemaCount} schema${summary.schemaCount !== 1 ? 's' : ''}`)}`, + '', + ]; + for (const artifact of artifacts) { + lines.push(` ${c.cyan(artifact.path)}`); + } + if (verbose) { + const warnings = diagnostics.filter(d => d.severity === 'warning'); + if (warnings.length > 0) { + lines.push('', c.bold(c.yellow(`Warnings (${warnings.length}):`))); + for (const w of warnings) { + lines.push(` ${c.yellow(`[${w.code}]`)} ${w.message}`); + } + } + } + return lines.join('\n'); +} + +// ── Init command ──────────────────────────────────────────────────────────── + +const DEFAULT_YAML_CONFIG = `# openapi-ng configuration +# https://github.com/AVSystem/openapi-ng + +input: ./openapi.yaml +# input: https://example.com/openapi.yaml # https:// URLs are also accepted +output: ./src/generated + +# emit: list of artifact families to produce. +# Valid entries: 'models', 'angular'. 'angular' depends on 'models'; +# it is auto-included if you omit it. +emit: + - models + - angular + +# mappedTypes: +# - schema: DateTime +# import: dayjs +# type: Dayjs + +# naming: +# methodName: '{operationId}' +# group: +# - format: '{tags[0]}' +# case: pascal +# - format: '{pathSegments[0]}' +# case: pascal +`; + +const DEFAULT_JSON_CONFIG = + JSON.stringify( + { + input: './openapi.yaml', + output: './src/generated', + emit: ['models', 'angular'], + }, + null, + 2, + ) + '\n'; + +const DEFAULT_TS_CONFIG = `import { defineConfig } from '@avsystem/openapi-ng/config'; + +export default defineConfig({ + input: './openapi.yaml', + // input: 'https://example.com/openapi.yaml', // https:// URLs are also accepted + output: './src/generated', + + // emit: ['models', 'angular'], + + // mappedTypes: [ + // { schema: 'DateTime', import: 'dayjs', type: 'Dayjs' }, + // ], + + // naming: { + // methodName: { + // from: '{operationId}', + // parse: /^[^_]+_(?.+)$/, + // format: '{capture.rest}', + // case: 'camel', + // }, + // group: [ + // { format: '{tags[0]}', case: 'pascal' }, + // { format: '{pathSegments[0]}', case: 'pascal' }, + // ], + // }, +}); +`; + +const DEFAULT_JS_CONFIG = `/** @type {import('@avsystem/openapi-ng').Config} */ +export default { + input: './openapi.yaml', + // input: 'https://example.com/openapi.yaml', // https:// URLs are also accepted + output: './src/generated', + + // emit: ['models', 'angular'], +}; +`; + +function runInit(format) { + const cwd = process.cwd(); + const existing = CONFIG_FILENAMES.find(name => fs.existsSync(path.join(cwd, name))); + if (existing !== undefined) { + process.stderr.write( + `${c.bold(c.red('Error'))} ${c.red('[E_INPUT_INVALID]')}\n` + + ` Cannot init: existing config file found at ${c.bold(existing)}\n` + + ` Remove it first, or use --config with the existing file.\n`, + ); + process.exitCode = 1; + return; + } + + let filename; + let template; + switch (format) { + case 'yaml': + filename = '.openapi-ng.yaml'; + template = DEFAULT_YAML_CONFIG; + break; + case 'json': + filename = '.openapi-ng.json'; + template = DEFAULT_JSON_CONFIG; + break; + case 'ts': + filename = 'openapi-ng.config.mts'; + template = DEFAULT_TS_CONFIG; + break; + case 'js': + filename = 'openapi-ng.config.mjs'; + template = DEFAULT_JS_CONFIG; + break; + default: + throw new Error(`Unknown init format: ${format}`); + } + + fs.writeFileSync(path.join(cwd, filename), template, 'utf8'); + process.stdout.write(`${c.bold(c.green('✓'))} Created ${c.bold(filename)}\n`); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main(argv) { + let parsed; + + try { + parsed = parseArgs(argv); + } catch (error) { + process.stderr.write(`${formatParseFailure(error)}\n`); + process.exitCode = 1; + return; + } + + if (parsed.kind === 'help') { + if (parsed.subcommand === 'generate') { + printGenerateUsage(); + } else if (parsed.subcommand === 'init') { + printInitUsage(); + } else { + printUsage(); + } + // Bare `openapi-ng` (no subcommand) is a usage error — exit 2 so CI + // scripts can catch a missing command. Explicit `--help` keeps exit 0. + if (parsed.explicit === false) { + process.exitCode = 2; + } + return; + } + + if (parsed.kind === 'version') { + const pkg = require('../package.json'); + process.stdout.write(`${pkg.version}\n`); + return; + } + + if (parsed.kind === 'init') { + runInit(parsed.format); + return; + } + + // Load config file for generate command + let fileConfig = {}; + try { + const configFilePath = parsed.configPath ?? discoverConfigPath(process.cwd()); + if (configFilePath) { + fileConfig = await loadConfigFile(configFilePath); + } + } catch (error) { + process.stderr.write(`${formatParseFailure(error)}\n`); + process.exitCode = 1; + return; + } + + // Generate command + let merged; + try { + merged = mergeConfig(fileConfig, parsed); + if (!merged.inputPath) { + throw new Error('Missing required --input argument.'); + } + } catch (error) { + process.stderr.write(`${formatParseFailure(error)}\n`); + process.exitCode = 1; + return; + } + + try { + const { generate } = loadLibrary(); + // Pass the user-provided inputPath verbatim. Relativisation of + // absolute paths inside CWD (for the generated-artifact banner) is + // owned by the Rust side in `render_generated_banner`, so the CLI + // and programmatic consumers (`generate({ inputPath: '/abs/...' })`) + // get the same banner-path hygiene without duplicated logic. + const result = await generate({ + inputPath: merged.inputPath, + outputPath: merged.outputPath ?? undefined, + emit: merged.emit, + mappedTypes: merged.mappedTypes ?? undefined, + responseTypeMapping: merged.responseTypeMapping ?? undefined, + naming: merged.naming ?? undefined, + }); + process.stdout.write(`${formatSuccess(result, merged.verbose)}\n`); + } catch (error) { + process.stderr.write(`${formatFailure(error)}\n`); + process.exitCode = 1; + } +} + +function formatParseFailure(error) { + // Honour error.code when set (e.g. loadConfigFile tags ENOENT and + // YAML/JSON parse failures with E_INPUT_INVALID — those are user + // input problems, not CLI option-parsing problems). Fall back to + // E_INVALID_OPTION only when no code is set, which is the + // parseArgs-raised case for genuinely bad flags. + const code = typeof error?.code === 'string' ? error.code : 'E_INVALID_OPTION'; + const message = typeof error?.message === 'string' ? error.message : String(error); + return `${c.bold(c.red('Error'))} ${c.red(`[${code}]`)}\n ${message}`; +} + +function formatFailure(error) { + if (typeof error?.code === 'string') { + const lines = [ + `${c.bold(c.red('Error'))} ${c.red(`[${error.code}]`)}`, + ` ${error.message}`, + ]; + const errorPath = error.path ?? error.warnings?.[0]?.path; + if (errorPath) { + lines.push(` ${c.dim(`in: ${errorPath}`)}`); + } + return lines.join('\n'); + } + if (typeof error?.message === 'string') { + return `${c.bold(c.red('Error'))} ${c.red('[E_UNEXPECTED]')}\n ${error.message}`; + } + return `${c.bold(c.red('Error'))} ${c.red('[E_UNEXPECTED]')}\n ${String(error)}`; +} + +// Defense-in-depth: surface any error escaping `main` (e.g. a future +// `await` added without a local try/catch) as a single human-readable +// stderr line and exit 1 — never the Node default "[UnhandledPromise +// Rejection]" multi-line stack dump. Today the inner paths all catch +// their own failures; this is the last-line guard. +main(process.argv.slice(2)).catch(err => { + process.stderr.write(`openapi-ng: ${err?.message ?? err}\n`); + process.exitCode = 1; +}); diff --git a/browser.js b/browser.js new file mode 100644 index 0000000..6ee3699 --- /dev/null +++ b/browser.js @@ -0,0 +1,33 @@ +'use strict'; + +// Browser/edge entry point. openapi-ng requires the native binding to +// generate code, so any browser or edge runtime (Vite/Webpack/esbuild +// resolving the `browser` field, Cloudflare Workers, Vercel Edge, etc.) +// gets a stub that throws `E_UNSUPPORTED_RUNTIME` at call time. The +// module itself stays importable so bundlers don't choke at build time. + +const { GenerateError } = require('./lib/generate-error.js'); + +async function generate() { + throw new GenerateError({ + code: 'E_UNSUPPORTED_RUNTIME', + message: + 'openapi-ng does not support browser or edge runtimes. ' + + 'Run the generator from Node, or remove openapi-ng from your browser bundle.', + warnings: [], + }); +} + +// Frozen runtime shape for `EmitTarget`. Mirrors the ambient const +// declared in `index.d.ts` and matches the `lib/index.js` entry, so the +// surface a consumer destructures is identical across both runtimes. +const EmitTarget = Object.freeze({ + Models: 'models', + Angular: 'angular', +}); + +module.exports = { + generate, + GenerateError, + EmitTarget, +}; diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..43918af --- /dev/null +++ b/build.rs @@ -0,0 +1,47 @@ +extern crate napi_build; + +use std::fs; +use std::path::Path; + +fn main() { + napi_build::setup(); + propagate_error_marker(); +} + +/// Read the shared `lib/error-marker.json` file at compile time and +/// expose the marker string to the Rust source via the +/// `OPENAPI_NG_ERROR_MARKER` env var (consumed via `env!()` in +/// `src/bindings.rs`). Keeping the marker in one file consumed by both +/// the Rust binding and `lib/index.js` makes drift impossible — the two +/// sides can never disagree on the sentinel that identifies a thrown +/// GenerateError across realms. +fn propagate_error_marker() { + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"); + let marker_path = Path::new(&manifest_dir) + .join("lib") + .join("error-marker.json"); + println!("cargo:rerun-if-changed={}", marker_path.display()); + + let raw = fs::read_to_string(&marker_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", marker_path.display())); + let marker = extract_marker(&raw).unwrap_or_else(|| { + panic!( + "missing or invalid `marker` field in {}", + marker_path.display() + ) + }); + println!("cargo:rustc-env=OPENAPI_NG_ERROR_MARKER={marker}"); +} + +/// Tiny JSON walker — we only need the value of the top-level +/// `"marker"` string, and pulling in a JSON crate at build time is more +/// machinery than the one-field file warrants. +fn extract_marker(raw: &str) -> Option { + let after_key = raw.split_once("\"marker\"")?.1; + let after_colon = after_key.split_once(':')?.1; + let trimmed = after_colon.trim_start(); + let after_open_quote = trimmed.strip_prefix('"')?; + let (value, _rest) = after_open_quote.split_once('"')?; + Some(value.to_string()) +} diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..0500ca3 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,363 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ +/** + * Per-target emit selection. The `emit` option is the set of artifact + * families to produce; each entry maps to one or more files. + */ +export type EmitTarget = 'models' | 'angular'; +export declare const EmitTarget: { + readonly Models: 'models'; + readonly Angular: 'angular'; +}; + +export declare function generate(options: GenerateOptions): Promise + +/** + * A single generated artifact. `contents` always carries the emitted + * source; callers that only need on-disk output can pass `outputPath` + * and ignore the array. + */ +export interface GeneratedArtifact { + path: string + contents: string +} + +/** + * Payload attached to every fatal native throw. The JS wrapper in + * `lib/index.js` upgrades the thrown plain Error into a `GenerateError` + * (a real JS class that extends Error), copying these own-properties + * across so consumers can `instanceof GenerateError` and still read + * `code/subcode/message/path/warnings`. + * + * The fatal sits at the top level (`code/subcode/message/path`); pre-fatal + * warnings ride in `warnings`. `subcode` is set for `PolicyViolation` + * codes; it is `null` for every other category. + */ +export interface GenerateErrorPayload { + code: DiagnosticCode + subcode: DiagnosticSubcode | null + message: string + path: string + warnings: Array +} + +export interface GenerateOptions { + /** + * Path to the spec on disk. Mutually exclusive with `input_contents`; + * the option validator rejects requests that set both or neither. + */ + inputPath?: string + /** + * Raw spec source. When set, `display_path` is required and the + * 16 MiB byte cap applies to `input_contents.as_bytes().len()`. + * JS wrapper fills this in for URL inputs. + */ + inputContents?: string + /** + * Banner / diagnostic display string. Required when `input_contents` + * is set; ignored when `input_path` is set (the existing path + * normalisation runs in that case). + */ + displayPath?: string + /** + * Decoder hint. Only honoured when `input_contents` is set; combining + * it with `input_path` is a shape error. + */ + inputFormat?: InputFormat + /** + * Optional. When undefined, generation runs in-memory (no files written). + * Passing an empty string is rejected at option resolution. + */ + outputPath?: string + emit?: Array + mappedTypes?: Array + /** + * Per-content-type override of the generated response-decoding kind + * (`json | blob | text | arrayBuffer`). Read by the normalize stage + * when picking how a successful response body is decoded. + */ + responseTypeMapping?: Array + naming?: NamingConfig +} + +export interface GenerateResult { + summary: GenerateSummary + diagnostics: Array + artifacts: Array +} + +export interface GenerateSummary { + /** + * Display-normalized path of the source spec, as it appears in the + * generated-artifact banner and in diagnostics' `path` field. Lets + * consumers correlate a result with the input they passed; the value + * is the supplied path with separators normalized, never resolved. + */ + normalizedSourcePath: string + specVersion: string + title: string + pathCount: number + operationCount: number + schemaCount: number +} + +/** + * Boundary projection of `Diagnostic` for the NAPI surface — string-typed + * `code` is what JS consumers see and compare against. `severity` is + * either `"warning"` or `"error"`; the TS surface narrows it to the + * `'warning' | 'error'` union via `scripts/patch-types.mjs`. `subcode` + * is populated only for `PolicyViolation` today; consumers route on it + * when they need finer-grained remediation than `code` alone. + */ +export interface GeneratorDiagnostic { + code: DiagnosticCode + subcode: DiagnosticSubcode | null + severity: 'warning' | 'error' + message: string + path: string +} + +/** + * Explicit decoder selection. Skips both extension-based detection and + * the JSON-then-YAML sniff fallback. Honoured only with `input_contents`. + */ +export declare const enum InputFormat { + Json = 'json', + Yaml = 'yaml' +} + +/** + * Canonical mapped-type record. Used as user input (from CLI/JS options) + * and as the planning record (after schema-name validation). + * + * Field names match the CLI YAML config vocabulary (schema/import/type/ + * alias). `ty` is the Rust-side name; the NAPI surface renames it to + * `type` so the JS API stays idiomatic. + */ +export interface MappedType { + schema: string + import: string + type: string + alias?: string +} + +export interface NamingChainItem { + string?: string + rule?: NamingRuleEntry +} + +/** + * User-facing naming config crossing the NAPI boundary. The JS wrapper + * in `lib/index.js` unpacks each JS `RegExp` into the `{ source, flags + * }` shape carried here, so Rust sees pure data on this side. + */ +export interface NamingOptions { + methodName?: NamingValue + group?: NamingValue +} + +export interface NamingParseSpec { + source: string + flags: string +} + +export interface NamingRuleEntry { + from?: string + parse?: NamingParseSpec + format?: string + /** Lowercase per spec: 'camel' | 'pascal' | 'snake' | 'kebab' | 'constant'. */ + case?: string +} + +/** + * Discriminated union: a string shorthand, a single rule, or a chain + * of rules-or-shorthands. NAPI cannot express true sum types, so we + * use exclusive fields: exactly one of `string`, `rule`, or `chain` + * must be set. The JS wrapper enforces this; the Rust validator + * double-checks at config resolution. + */ +export interface NamingValue { + /** `{ string: '...' }` — bare format-string shorthand. */ + string?: string + /** `{ rule: { ... } }` — a single Rule. */ + rule?: NamingRuleEntry + /** + * `{ chain: [...] }` — a sequence; each item is an exclusive + * `{ string }` or `{ rule }`. + */ + chain?: Array +} + +/** + * JS-facing response-kind values. Mirrors the names Angular's + * `HttpClient.request({ responseType })` and `httpResource.()` + * expose, so the config vocabulary stays in JS conventions. The emit + * boundary translates `ArrayBuffer` to the lowercase `'arraybuffer'` + * string `HttpClient.request` requires. + */ +export declare const enum ResponseType { + Json = 'json', + Blob = 'blob', + Text = 'text', + ArrayBuffer = 'arrayBuffer' +} + +/** + * User mapping: override the response-kind decoded for a specific + * response content-type. Pure data — Phase-3 normalize-side reads + * this when picking the `responseKind` for an operation's response + * content. Keys are matched case-insensitively against the lowercased + * media-type from the spec; the `responseType` is one of the JS-facing + * HttpClient response kinds (`'json' | 'blob' | 'text' | 'arrayBuffer'`). + */ +export interface ResponseTypeMapping { + contentType: string + responseType: ResponseType +} + +// Hand-authored tail concatenated onto the napi-rs-generated index.d.ts +// by scripts/patch-types.mjs. Contains TS surface that napi-rs cannot +// emit on its own (named string-literal unions, the GenerateError class +// defined in lib/index.js). + +/** + * Diagnostic code taxonomy. Each value maps 1:1 to a Rust DiagnosticCode, + * except `E_UNEXPECTED` and `E_UNSUPPORTED_RUNTIME`, which are reserved + * for the JS wrapper in `lib/index.js`: + * - `E_UNEXPECTED` is a defensive fallback when a `GenerateError` is + * constructed without an explicit `code`. + * - `E_UNSUPPORTED_RUNTIME` is thrown by `browser.js` when `generate()` + * is called in a browser or edge runtime (no native binding available). + * + * `E_INVALID_OPTION` covers both wrapper-side shape rejections (a JS + * caller passed an option whose type/shape is wrong — surfaced with + * `subcode: 'shape'`) and Rust-side semantic rejections of option values + * (surfaced with no subcode). + */ +export type DiagnosticCode = + | 'E_INPUT_INVALID' + | 'E_UNSUPPORTED_SEMANTIC' + | 'E_UNSUPPORTED_RUNTIME' + | 'E_INVALID_REFERENCE' + | 'E_INVALID_OPTION' + | 'E_POLICY_VIOLATION' + | 'E_WRITE_FAILED' + | 'E_UNEXPECTED'; + +/** + * Finer-grained taxonomy projected from `Diagnostic.subcode`. Emitted for + * `E_POLICY_VIOLATION` diagnostics, a subset of `E_UNSUPPORTED_SEMANTIC` + * warnings, and wrapper-side `E_INVALID_OPTION` shape rejections + * (`'shape'`) so consumers can route remediation without parsing the + * message. Null for every diagnostic that does not carry a sub-class. + */ +export type DiagnosticSubcode = + | 'shape' + | 'missing-operation-id' + | 'missing-tag' + | 'field-collision' + | 'missing-discriminator-property' + | 'unsupported-parameter-location' + | 'duplicate-operation-id' + | 'duplicate-schema-name' + | 'format-dropped' + | 'schema-cap-exceeded' + | 'operation-cap-exceeded' + | 'mapping-expansion-exceeded' + | 'naming-resolution' + | 'multi-content-body' + | 'unsupported-body-content-type' + | 'multipart-nested-object' + | 'multipart-composed-field' + | 'multipart-non-object-body' + | 'multipart-open-schema' + | 'urlencoded-binary-field' + | 'urlencoded-nested-object' + | 'urlencoded-composed-field' + | 'urlencoded-non-object-body' + | 'urlencoded-open-schema'; + +/** + * Thrown by `generate` on any fatal diagnostic. Real JS class + * that extends `Error`, so consumers can write `err instanceof + * GenerateError`. + * + * Defined in `lib/index.js` (not by napi-rs) — the native binding + * throws a plain Error carrying these own-properties and a marker that + * the JS wrapper uses to upgrade it. + */ +export declare class GenerateError extends Error { + constructor(payload: GenerateErrorPayload); + readonly code: DiagnosticCode; + readonly subcode: DiagnosticSubcode | null; + readonly path: string; + readonly warnings: Array; + + /** + * Cross-realm-safe predicate. `instanceof GenerateError` only matches + * inside the realm where `openapi-ng` was loaded (a different vm + * context / worker thread / iframe has a different class identity). + * The internal sentinel survives the realm boundary, so consumers + * who run across realms should prefer this check. + */ + static isGenerateError(value: unknown): value is GenerateError; +} + +/** + * Case transformations supported by naming rules (see `docs/naming-spec.md`). + */ +export type Case = 'camel' | 'pascal' | 'snake' | 'kebab' | 'constant'; + +/** + * One rule in a naming chain. All four fields are optional. The + * evaluation order is: expand `from` → match `parse` → expand `format` + * (defaults to expanded `from` when omitted; required when `parse` is + * present) → apply `case`. A rule fails when any template reference is + * unbound or `parse` does not match. + */ +export interface NamingRule { + from?: string; + parse?: RegExp; + format?: string; + case?: Case; +} + +/** + * A bare string is shorthand for `{ format: string }` with no case + * transformation. An array is a fallback chain — the first rule that + * does not fail wins; if every rule fails the generation errors with + * `E_POLICY_VIOLATION` subcode `'naming-resolution'`. + */ +export type Naming = string | NamingRule | Array; + +export interface NamingConfig { + methodName?: Naming; + group?: Naming; +} + +/** + * File-config shape — accepted by `.openapi-ng.{yaml,json}`, + * `openapi-ng.config.{ts,mts,cts,mjs,js,cjs}`, and the `defineConfig` + * helper. Keys mirror the YAML/JSON surface (`input`/`output`), NOT + * the programmatic `generate({ inputPath, outputPath, ... })` surface. + */ +export interface Config { + input?: string; + output?: string; + emit?: Array; + mappedTypes?: Array; + responseTypeMapping?: Array; + naming?: NamingConfig; +} + +/** + * Identity helper that anchors TypeScript inference for JS/TS configs: + * import { defineConfig } from '@avsystem/openapi-ng/config'; + * export default defineConfig({ input: 'spec.yaml', output: 'out' }); + * + * Returns its argument unchanged. Has no runtime behaviour. + * + * Declared here so the `./config` subpath export resolves to the same + * `.d.ts` file (one published types surface). + */ +export declare function defineConfig(config: Config): Config; diff --git a/index.d.ts.in b/index.d.ts.in new file mode 100644 index 0000000..0a6b7b8 --- /dev/null +++ b/index.d.ts.in @@ -0,0 +1,146 @@ +// Hand-authored tail concatenated onto the napi-rs-generated index.d.ts +// by scripts/patch-types.mjs. Contains TS surface that napi-rs cannot +// emit on its own (named string-literal unions, the GenerateError class +// defined in lib/index.js). + +/** + * Diagnostic code taxonomy. Each value maps 1:1 to a Rust DiagnosticCode, + * except `E_UNEXPECTED` and `E_UNSUPPORTED_RUNTIME`, which are reserved + * for the JS wrapper in `lib/index.js`: + * - `E_UNEXPECTED` is a defensive fallback when a `GenerateError` is + * constructed without an explicit `code`. + * - `E_UNSUPPORTED_RUNTIME` is thrown by `browser.js` when `generate()` + * is called in a browser or edge runtime (no native binding available). + * + * `E_INVALID_OPTION` covers both wrapper-side shape rejections (a JS + * caller passed an option whose type/shape is wrong — surfaced with + * `subcode: 'shape'`) and Rust-side semantic rejections of option values + * (surfaced with no subcode). + */ +export type DiagnosticCode = + | 'E_INPUT_INVALID' + | 'E_UNSUPPORTED_SEMANTIC' + | 'E_UNSUPPORTED_RUNTIME' + | 'E_INVALID_REFERENCE' + | 'E_INVALID_OPTION' + | 'E_POLICY_VIOLATION' + | 'E_WRITE_FAILED' + | 'E_UNEXPECTED'; + +/** + * Finer-grained taxonomy projected from `Diagnostic.subcode`. Emitted for + * `E_POLICY_VIOLATION` diagnostics, a subset of `E_UNSUPPORTED_SEMANTIC` + * warnings, and wrapper-side `E_INVALID_OPTION` shape rejections + * (`'shape'`) so consumers can route remediation without parsing the + * message. Null for every diagnostic that does not carry a sub-class. + */ +export type DiagnosticSubcode = + | 'shape' + | 'missing-operation-id' + | 'missing-tag' + | 'field-collision' + | 'missing-discriminator-property' + | 'unsupported-parameter-location' + | 'duplicate-operation-id' + | 'duplicate-schema-name' + | 'format-dropped' + | 'schema-cap-exceeded' + | 'operation-cap-exceeded' + | 'mapping-expansion-exceeded' + | 'naming-resolution' + | 'multi-content-body' + | 'unsupported-body-content-type' + | 'multipart-nested-object' + | 'multipart-composed-field' + | 'multipart-non-object-body' + | 'multipart-open-schema' + | 'urlencoded-binary-field' + | 'urlencoded-nested-object' + | 'urlencoded-composed-field' + | 'urlencoded-non-object-body' + | 'urlencoded-open-schema'; + +/** + * Thrown by `generate` on any fatal diagnostic. Real JS class + * that extends `Error`, so consumers can write `err instanceof + * GenerateError`. + * + * Defined in `lib/index.js` (not by napi-rs) — the native binding + * throws a plain Error carrying these own-properties and a marker that + * the JS wrapper uses to upgrade it. + */ +export declare class GenerateError extends Error { + constructor(payload: GenerateErrorPayload); + readonly code: DiagnosticCode; + readonly subcode: DiagnosticSubcode | null; + readonly path: string; + readonly warnings: Array; + + /** + * Cross-realm-safe predicate. `instanceof GenerateError` only matches + * inside the realm where `openapi-ng` was loaded (a different vm + * context / worker thread / iframe has a different class identity). + * The internal sentinel survives the realm boundary, so consumers + * who run across realms should prefer this check. + */ + static isGenerateError(value: unknown): value is GenerateError; +} + +/** + * Case transformations supported by naming rules (see `docs/naming-spec.md`). + */ +export type Case = 'camel' | 'pascal' | 'snake' | 'kebab' | 'constant'; + +/** + * One rule in a naming chain. All four fields are optional. The + * evaluation order is: expand `from` → match `parse` → expand `format` + * (defaults to expanded `from` when omitted; required when `parse` is + * present) → apply `case`. A rule fails when any template reference is + * unbound or `parse` does not match. + */ +export interface NamingRule { + from?: string; + parse?: RegExp; + format?: string; + case?: Case; +} + +/** + * A bare string is shorthand for `{ format: string }` with no case + * transformation. An array is a fallback chain — the first rule that + * does not fail wins; if every rule fails the generation errors with + * `E_POLICY_VIOLATION` subcode `'naming-resolution'`. + */ +export type Naming = string | NamingRule | Array; + +export interface NamingConfig { + methodName?: Naming; + group?: Naming; +} + +/** + * File-config shape — accepted by `.openapi-ng.{yaml,json}`, + * `openapi-ng.config.{ts,mts,cts,mjs,js,cjs}`, and the `defineConfig` + * helper. Keys mirror the YAML/JSON surface (`input`/`output`), NOT + * the programmatic `generate({ inputPath, outputPath, ... })` surface. + */ +export interface Config { + input?: string; + output?: string; + emit?: Array; + mappedTypes?: Array; + responseTypeMapping?: Array; + naming?: NamingConfig; +} + +/** + * Identity helper that anchors TypeScript inference for JS/TS configs: + * import { defineConfig } from '@avsystem/openapi-ng/config'; + * export default defineConfig({ input: 'spec.yaml', output: 'out' }); + * + * Returns its argument unchanged. Has no runtime behaviour. + * + * Declared here so the `./config` subpath export resolves to the same + * `.d.ts` file (one published types surface). + */ +export declare function defineConfig(config: Config): Config; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..7c86afe --- /dev/null +++ b/lib/config.js @@ -0,0 +1,10 @@ +'use strict'; + +// Identity helper so JS/TS configs can opt into TypeScript inference via +// import { defineConfig } from '@avsystem/openapi-ng/config'; +// Returns the argument unchanged. Has no runtime behaviour. +function defineConfig(config) { + return config; +} + +module.exports = { defineConfig }; diff --git a/lib/error-marker.json b/lib/error-marker.json new file mode 100644 index 0000000..40e8494 --- /dev/null +++ b/lib/error-marker.json @@ -0,0 +1,3 @@ +{ + "marker": "__openapiNgGenerateError" +} diff --git a/lib/fetch-input.js b/lib/fetch-input.js new file mode 100644 index 0000000..313c4f9 --- /dev/null +++ b/lib/fetch-input.js @@ -0,0 +1,292 @@ +'use strict'; + +const net = require('node:net'); + +const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_REDIRECTS = 5; + +// Accept https-or-http; the scheme check itself runs inside fetchInput +// where the http case turns into a typed error. This predicate just +// decides "is this a URL or a path". +function isUrl(value) { + if (typeof value !== 'string') return false; + return /^https?:\/\//i.test(value); +} + +function isHttpsUrl(value) { + return /^https:\/\//i.test(value); +} + +function inputError(message) { + const e = new Error(message); + e.code = 'E_INPUT_INVALID'; + return e; +} + +// Tolerate the browser/WASI entry where `process` may not exist. The +// surrounding logic (size cap default, timeout default, the new +// OPENAPI_NG_ALLOW_PRIVATE_HOSTS opt-out) all assume read-or-fall-back +// semantics, so an undefined `process` collapses to the default branch. +function safeEnv(name) { + if (typeof process === 'undefined' || !process.env) return undefined; + return process.env[name]; +} + +function envInt(name, defaultValue) { + const raw = safeEnv(name); + if (raw === undefined) return defaultValue; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0) return defaultValue; + return n; +} + +// IPv4 + IPv6 ranges we refuse to resolve to. The list covers cloud +// metadata services (`169.254.169.254`), RFC1918 LAN ranges, loopback, +// link-local, CGNAT, ULA, and IPv4-mapped IPv6 — every shape that lets +// a user-supplied URL escape into private infrastructure. Built once +// per process (module load), not per-request, so the BlockList is hot. +const BLOCKED_HOSTS = (() => { + const list = new net.BlockList(); + list.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback + list.addSubnet('10.0.0.0', 8, 'ipv4'); // RFC1918 + list.addSubnet('172.16.0.0', 12, 'ipv4'); // RFC1918 + list.addSubnet('192.168.0.0', 16, 'ipv4'); // RFC1918 + list.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local + AWS IMDS + list.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT + list.addAddress('0.0.0.0', 'ipv4'); + list.addAddress('::1', 'ipv6'); + list.addSubnet('fc00::', 7, 'ipv6'); // ULA + list.addSubnet('fe80::', 10, 'ipv6'); // link-local + // node:net's BlockList performs dual-stack matching automatically: + // checking a literal `::ffff:127.0.0.1` as ipv6 hits the loopback + // subnet added above. Adding an explicit `::ffff:0.0.0.0/96` range + // would also match every public IPv4 (1.1.1.1, …) via the same + // mapping, so it's intentionally omitted. + return list; +})(); + +// DNS lookup is reachable via an injection slot so the Ava test suite +// can stub it without spinning up a real resolver. Default lazily loads +// `node:dns/promises` so a runtime without DNS (e.g. some sandboxes) +// can still parse this module — the lookup only fires if a non-IP host +// is being resolved AND the SSRF guard is active. +let _testDnsLookup = null; +function __setDnsLookupForTest(impl) { + _testDnsLookup = impl; +} +function _resolveDnsLookup() { + if (_testDnsLookup !== null) return _testDnsLookup; + // Lazy: only require dns when we actually need to resolve a name. + const dns = require('node:dns/promises'); + return (host, options) => dns.lookup(host, options); +} + +// Walk each hop's URL through the BlockList before letting `fetchImpl` +// see it. Re-checking inside the redirect loop closes the "redirect +// from a public host to 169.254.169.254" loophole — a CNAME or 302 +// chain that starts public but lands on metadata would otherwise slip +// past a one-shot check at entry. +async function assertPublicHost(urlStr) { + if (safeEnv('OPENAPI_NG_ALLOW_PRIVATE_HOSTS') === '1') return; + const { hostname } = new URL(urlStr); + // IPv6 literals come URL-encoded as `[…]`; strip the brackets before + // handing the bare address to net.isIP / dns.lookup. + const host = hostname.replace(/^\[|\]$/g, ''); + const ipKind = net.isIP(host); + const targets = ipKind + ? [{ address: host, family: ipKind === 6 ? 6 : 4 }] + : await _resolveDnsLookup()(host, { all: true }); + for (const { address, family } of targets) { + const familyStr = family === 6 ? 'ipv6' : 'ipv4'; + if (BLOCKED_HOSTS.check(address, familyStr)) { + throw inputError( + `Refusing to fetch ${urlStr}: host ${host} resolves to a ` + + `private/loopback/link-local address (${address}). ` + + `Set OPENAPI_NG_ALLOW_PRIVATE_HOSTS=1 to override.`, + ); + } + } +} + +// Parse a media type. Returns { type, subtype, suffix } or null. +// "application/openapi+yaml; charset=utf-8" -> { type: 'application', +// subtype: 'openapi', suffix: 'yaml' }. +function parseMediaType(ct) { + if (typeof ct !== 'string' || ct.length === 0) return null; + const bare = ct.split(';', 1)[0].trim().toLowerCase(); + const slash = bare.indexOf('/'); + if (slash === -1) return null; + const type = bare.slice(0, slash); + const subtypeAndSuffix = bare.slice(slash + 1); + const plus = subtypeAndSuffix.lastIndexOf('+'); + if (plus === -1) { + return { type, subtype: subtypeAndSuffix, suffix: null }; + } + return { + type, + subtype: subtypeAndSuffix.slice(0, plus), + suffix: subtypeAndSuffix.slice(plus + 1), + }; +} + +function formatFromContentType(ct) { + const mt = parseMediaType(ct); + if (mt === null) return null; + if (mt.subtype === 'json' || mt.suffix === 'json') return 'json'; + if (mt.subtype === 'yaml' || mt.subtype === 'x-yaml') return 'yaml'; + if (mt.subtype.endsWith('.yaml')) return 'yaml'; + if (mt.subtype.endsWith('.x-yaml')) return 'yaml'; + if (mt.suffix === 'yaml') return 'yaml'; + return null; +} + +function formatFromUrlPath(urlStr) { + try { + const u = new URL(urlStr); + const ext = u.pathname.slice(u.pathname.lastIndexOf('.')).toLowerCase(); + if (ext === '.json') return 'json'; + if (ext === '.yaml' || ext === '.yml') return 'yaml'; + return null; + } catch { + return null; + } +} + +const FOLLOWED_STATUSES = new Set([301, 302, 303, 307, 308]); + +// Test affordance: allow indirect callers (like `lib/index.js`) to route +// through `fetchInput` without surfacing `fetchImpl` as a public option. +// The setter mutates a module-level slot consulted by `_resolveFetchImpl`, +// which is the new default when `fetchImpl` is not passed explicitly. +let _testFetchImpl = null; +function __setFetchImplForTest(impl) { + _testFetchImpl = impl; +} +function _resolveFetchImpl() { + return _testFetchImpl ?? globalThis.fetch; +} + +async function fetchInput(url, options = {}) { + const { + fetchImpl = _resolveFetchImpl(), + maxBytes = envInt('OPENAPI_NG_MAX_INPUT_BYTES', DEFAULT_MAX_BYTES), + timeoutMs = envInt('OPENAPI_NG_INPUT_TIMEOUT_MS', DEFAULT_TIMEOUT_MS), + } = options; + + if (!isHttpsUrl(url)) { + throw inputError(`only https:// URLs are accepted; got '${url}'`); + } + + // One AbortSignal across all redirects — that's what makes the + // timeout a true wall-clock cap on the whole operation. + const signal = AbortSignal.timeout(timeoutMs); + + let currentUrl = url; + let response; + for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { + // Re-validate every hop: a 302 from a public host to a private one + // would otherwise slip past a one-shot check at entry. + await assertPublicHost(currentUrl); + let raw; + try { + raw = await fetchImpl(currentUrl, { redirect: 'manual', signal }); + } catch (err) { + if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) { + throw inputError(`Fetch timed out after ${timeoutMs}ms`); + } + const cause = err?.cause; + const reason = cause?.code ?? cause?.message ?? err?.message ?? String(err); + throw inputError(`Network fetch failed: ${reason}`); + } + + if (FOLLOWED_STATUSES.has(raw.status)) { + if (hop === MAX_REDIRECTS) { + throw inputError(`Redirect chain exceeded ${MAX_REDIRECTS} hops`); + } + const location = raw.headers.get('location'); + if (location === null) { + throw inputError(`HTTP ${raw.status} ${raw.statusText} with no Location header`); + } + let next; + try { + next = new URL(location, currentUrl).toString(); + } catch { + throw inputError(`Invalid redirect Location: ${location}`); + } + if (!isHttpsUrl(next)) { + throw inputError(`Refusing https to http downgrade redirect: ${next}`); + } + currentUrl = next; + continue; + } + + if (raw.status < 200 || raw.status >= 300) { + throw inputError(`HTTP ${raw.status} ${raw.statusText || ''}`.trimEnd()); + } + + response = raw; + break; + } + + // Size cap stage 1: Content-Length pre-check. + const cl = response.headers.get('content-length'); + if (cl !== null) { + const n = Number.parseInt(cl, 10); + if (Number.isFinite(n) && n > maxBytes) { + throw inputError( + `Response is ${n} bytes, exceeds maximum of ${maxBytes} bytes. ` + + `Set OPENAPI_NG_MAX_INPUT_BYTES to override.`, + ); + } + } + + // Size cap stage 2: streamed accumulation. + const reader = response.body?.getReader(); + if (!reader) { + return { + contents: '', + contentType: response.headers.get('content-type'), + finalUrl: currentUrl, + format: null, + }; + } + const decoder = new TextDecoder('utf-8'); + let received = 0; + let contents = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > maxBytes) { + try { + await reader.cancel(); + } catch { + /* best effort */ + } + throw inputError( + `Response exceeds maximum of ${maxBytes} bytes. ` + + `Set OPENAPI_NG_MAX_INPUT_BYTES to override.`, + ); + } + contents += decoder.decode(value, { stream: true }); + } + contents += decoder.decode(); + + const contentType = response.headers.get('content-type'); + const format = formatFromContentType(contentType) ?? formatFromUrlPath(url); + + return { contents, contentType, finalUrl: currentUrl, format }; +} + +module.exports = { + fetchInput, + isUrl, + isHttpsUrl, + formatFromContentType, + formatFromUrlPath, + __setFetchImplForTest, + _resolveFetchImpl, + __setDnsLookupForTest, + _resolveDnsLookup, +}; diff --git a/lib/generate-error.js b/lib/generate-error.js new file mode 100644 index 0000000..19bb5e6 --- /dev/null +++ b/lib/generate-error.js @@ -0,0 +1,25 @@ +'use strict'; + +const { marker: MARKER } = require('./error-marker.json'); + +class GenerateError extends Error { + constructor(payload) { + super(payload?.message ?? 'openapi-ng: generation failed'); + this.name = 'GenerateError'; + this.code = payload?.code ?? 'E_UNEXPECTED'; + this.subcode = payload?.subcode ?? null; + this.path = payload?.path ?? ''; + this.warnings = Array.isArray(payload?.warnings) ? payload.warnings : []; + Object.defineProperty(this, MARKER, { value: true, enumerable: false }); + } + + // Cross-realm-safe predicate. `instanceof GenerateError` only works + // inside the realm where this module was loaded; the sentinel own- + // property survives the realm boundary, so consumers crossing realms + // should use `GenerateError.isGenerateError(err)` instead. + static isGenerateError(value) { + return Boolean(value) && typeof value === 'object' && value[MARKER] === true; + } +} + +module.exports = { GenerateError }; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..39daa75 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,52 @@ +'use strict'; + +// Test affordance: set OPENAPI_NG_DISABLE_NATIVE_FOR_TEST=1 to prevent the +// native binding from loading. Used to verify lazy-load behaviour in CLI tests. +if (process.env.OPENAPI_NG_DISABLE_NATIVE_FOR_TEST === '1') { + throw new Error('native binding load is disabled for this test'); +} + +// Node entry point. The native binding sits at ../native.js (auto-generated +// by napi-rs); this wrapper upgrades thrown errors into a real +// `GenerateError` JS class that extends `Error`, so consumers can write +// `err instanceof GenerateError`. The native binding itself only attaches +// own-properties to a plain Error — making the class subclass `Error` on +// the JS side is the simplest path that survives napi-rs's class +// registration not setting the Error prototype. +// +// The native binding is NOT a published entry point — `package.json#main` +// only exposes `lib/index.js`, so consumers can never bypass this wrapper. +// +// Shared option normalisation, validation, and URL-fetch ergonomics live +// in `lib/wrapper-core.js` so the browser/WASI entry (`browser.js`) can +// reuse them; this file is the Node-specific seam that binds them to +// `../native.js`. + +const native = require('../native.js'); +const { GenerateError } = require('./generate-error.js'); +const { fetchInput } = require('./fetch-input.js'); +const { prepareOptions, upgradeError } = require('./wrapper-core.js'); + +async function generate(options) { + const prepared = await prepareOptions(options, fetchInput); + try { + return native.generate(prepared); + } catch (err) { + throw upgradeError(err); + } +} + +// Frozen runtime shape for `EmitTarget`. Mirrors the ambient const +// declared in `index.d.ts` and matches the `browser.js` entry, so the +// surface a consumer destructures is identical across both runtimes and +// no longer depends on napi-rs's string-enum machinery. +const EmitTarget = Object.freeze({ + Models: 'models', + Angular: 'angular', +}); + +module.exports = { + generate, + GenerateError, + EmitTarget, +}; diff --git a/lib/wrapper-core.js b/lib/wrapper-core.js new file mode 100644 index 0000000..5cd1315 --- /dev/null +++ b/lib/wrapper-core.js @@ -0,0 +1,340 @@ +'use strict'; + +// Wrapper logic for the Node entry (`lib/index.js`): option normalisation +// and validation, URL-fetch ergonomics, and error upgrade around the native +// binding. Lives here rather than inline in `lib/index.js` to keep the entry +// thin and to make the pre-NAPI option surface independently testable. + +const { GenerateError } = require('./generate-error.js'); + +// Frozen allow-list of recognised option keys. The native binding silently +// ignores anything else; surfacing unknown keys here means typos +// (`inputpath:` → undefined) fail fast with a typed `GenerateError` +// instead of producing confusing downstream diagnostics. Keep in sync +// with `GenerateOptions` in `src/bindings.rs`. +const GENERATE_OPTION_KEYS = Object.freeze( + new Set([ + 'inputPath', + 'inputContents', + 'displayPath', + 'inputFormat', + 'outputPath', + 'emit', + 'mappedTypes', + 'responseTypeMapping', + 'naming', + ]), +); + +// Frozen allow-list of recognised `EmitTarget` runtime values. Duplicates +// `VALID_EMIT_TARGETS` in `bin/lib/parse.js` on purpose: the CLI and the +// programmatic API are independent boundaries, each performing its own +// entry-level validation. Both reflect the same truth declared in +// `EmitTarget` (see `index.d.ts`); keep them in sync. +const VALID_EMIT = Object.freeze(new Set(['models', 'angular'])); + +// The five case transformations supported by naming rules (see +// `docs/naming-spec.md`). Mirrored on the Rust side; both must accept +// the same lowercase strings. +const VALID_CASES = Object.freeze( + new Set(['camel', 'pascal', 'snake', 'kebab', 'constant']), +); + +// Default emit set. Mirrors `DEFAULT_EMIT` in `bin/lib/parse.js` so the +// programmatic API matches the CLI: a consumer who calls `generate({ +// inputPath })` without an explicit `emit` list gets the same artifacts as +// `openapi-ng generate -i ...`. Frozen so a consumer mutating the +// returned options object can't corrupt the next call's default. +const DEFAULT_EMIT = Object.freeze(['models', 'angular']); + +// Lower one chain item (either a string or a Rule-shaped object) into +// the `{ string }` or `{ rule }` shape the Rust side expects. +function normalizeNamingEntry(entry, path) { + if (typeof entry === 'string') { + return { string: entry }; + } + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: `naming.${path}: each entry must be a string or a Rule object.`, + warnings: [], + }); + } + if (entry.case !== undefined && !VALID_CASES.has(entry.case)) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: `naming.${path}.case: '${entry.case}' is not one of 'camel', 'pascal', 'snake', 'kebab', 'constant'.`, + warnings: [], + }); + } + let parse; + if (entry.parse !== undefined) { + if (entry.parse instanceof RegExp) { + parse = { source: entry.parse.source, flags: entry.parse.flags }; + } else if ( + entry.parse && + typeof entry.parse.source === 'string' && + typeof entry.parse.flags === 'string' + ) { + parse = { source: entry.parse.source, flags: entry.parse.flags }; + } else { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: `naming.${path}.parse: must be a RegExp or {source, flags} object.`, + warnings: [], + }); + } + } + return { + rule: { + from: entry.from, + parse, + format: entry.format, + case: entry.case, + }, + }; +} + +// Lower the top-level `methodName` or `group` value into the NamingValue +// shape: { string } | { rule } | { chain: [...] }. Returns undefined +// when the input is undefined (keeps the option absent on the Rust side). +function normalizeNamingValue(value, key) { + if (value === undefined) return undefined; + if (typeof value === 'string') { + return { string: value }; + } + if (Array.isArray(value)) { + return { + chain: value.map((item, i) => normalizeNamingEntry(item, `${key}[${i}]`)), + }; + } + // Single rule object: lower it as a chain item, then promote to a + // top-level `{ rule }` value. + const entry = normalizeNamingEntry(value, key); + return { rule: entry.rule }; +} + +function upgradeError(err) { + if (GenerateError.isGenerateError(err)) { + const upgraded = new GenerateError({ + code: err.code, + subcode: err.subcode, + message: err.message, + path: err.path, + warnings: err.warnings, + }); + if (typeof err.stack === 'string') upgraded.stack = err.stack; + return upgraded; + } + return err; +} + +function validateGenerateOptions(options) { + if (options === null || typeof options !== 'object') { + return; + } + const unknown = []; + for (const key of Object.keys(options)) { + if (!GENERATE_OPTION_KEYS.has(key)) unknown.push(key); + } + if (unknown.length > 0) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + message: + `Unknown generate option(s): ${unknown.map(k => `'${k}'`).join(', ')}. ` + + `Allowed: ${[...GENERATE_OPTION_KEYS].map(k => `'${k}'`).join(', ')}.`, + warnings: [], + }); + } + // Shape checks for the typed fields. NAPI rejects a wrong type on the + // Rust side, but the error there is generic ("Failed to convert"); + // catching the mistake here gives the consumer a typed GenerateError + // with a message that names the offending option and (where helpful) + // the offending value. + const hasInputPath = typeof options.inputPath === 'string' && options.inputPath !== ''; + const hasInputContents = typeof options.inputContents === 'string'; + + if (hasInputPath === hasInputContents) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: + 'Must set exactly one of inputPath (non-empty string) or inputContents (string).', + warnings: [], + }); + } + + if (hasInputContents) { + if (typeof options.displayPath !== 'string' || options.displayPath === '') { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: 'displayPath (non-empty string) is required when inputContents is set.', + warnings: [], + }); + } + } else if (options.displayPath !== undefined) { + // displayPath with inputPath is ignored on the Rust side, but accepting + // it silently would let a misconfigured caller think it's being used. + // Reject loudly. + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: + 'displayPath is only used with inputContents; remove it when passing inputPath.', + warnings: [], + }); + } + + if (options.inputFormat !== undefined) { + if (!hasInputContents) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: 'inputFormat is only honoured with inputContents.', + warnings: [], + }); + } + if (options.inputFormat !== 'json' && options.inputFormat !== 'yaml') { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: `inputFormat must be 'json' or 'yaml'; got '${options.inputFormat}'.`, + warnings: [], + }); + } + } + if (!Array.isArray(options.emit)) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: 'emit must be an array of EmitTarget', + warnings: [], + }); + } + for (const target of options.emit) { + if (!VALID_EMIT.has(target)) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: `emit contains invalid target '${target}'. Allowed: 'models', 'angular'.`, + warnings: [], + }); + } + } + if (options.mappedTypes !== undefined) { + if (!Array.isArray(options.mappedTypes)) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: 'mappedTypes must be an array', + warnings: [], + }); + } + for (let i = 0; i < options.mappedTypes.length; i++) { + const mt = options.mappedTypes[i]; + if ( + typeof mt?.schema !== 'string' || + typeof mt?.import !== 'string' || + typeof mt?.type !== 'string' + ) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: `mappedTypes[${i}] must have string 'schema', 'import', and 'type' fields`, + warnings: [], + }); + } + } + } + if (options.naming !== undefined) { + if ( + options.naming === null || + typeof options.naming !== 'object' || + Array.isArray(options.naming) + ) { + throw new GenerateError({ + code: 'E_INVALID_OPTION', + subcode: 'shape', + message: 'naming must be an object with optional `methodName` and `group` keys', + warnings: [], + }); + } + } +} + +// Compute the lowered `naming` value the binding expects, without +// touching the caller's object. Returns undefined when `options.naming` +// is absent, so the caller can omit the key entirely. +function normalizeNaming(options) { + if (options == null || typeof options !== 'object') return undefined; + if (options.naming === undefined) return undefined; + return { + methodName: normalizeNamingValue(options.naming.methodName, 'methodName'), + group: normalizeNamingValue(options.naming.group, 'group'), + }; +} + +// Normalise + validate options for both entries. Applies the CLI-parity +// emit default, transparently fetches https URLs into inputContents, and +// runs the shape validator. `fetchInputFn` is injected so the browser +// entry can pass its own fetch implementation if needed; the Node entry +// passes `lib/fetch-input.js`'s `fetchInput`. +async function prepareOptions(options, fetchInputFn) { + // Apply the CLI-parity default BEFORE validation so the validator and + // the binding boundary both see a populated emit set. If `options` is + // anything other than an object, leave it untouched and let + // `validateGenerateOptions` (a no-op for non-objects) defer to the + // binding's own type rejection. + const normalized = + options != null && typeof options === 'object' && options.emit === undefined + ? { ...options, emit: [...DEFAULT_EMIT] } + : options; + + // URL branch: if inputPath is a URL string, fetch and rewrite into + // the inputContents form before validation. The wrapper-side + // validator treats inputContents as the operative input from here on. + // The http:// case is rejected inside fetchInput's scheme check — + // duplicating the check here would just split the error message + // surface across two layers. + if ( + normalized != null && + typeof normalized === 'object' && + typeof normalized.inputPath === 'string' && + /^https?:\/\//i.test(normalized.inputPath) && + normalized.inputContents === undefined + ) { + const url = normalized.inputPath; + const fetched = await fetchInputFn(url); + const rewritten = { ...normalized }; + delete rewritten.inputPath; + rewritten.inputContents = fetched.contents; + // Use the original URL (not finalUrl after redirects) so banners + // show what the consumer actually asked for. + rewritten.displayPath = url; + if (fetched.format !== null) { + rewritten.inputFormat = fetched.format; + } + validateGenerateOptions(rewritten); + const naming = normalizeNaming(rewritten); + return naming === undefined ? rewritten : { ...rewritten, naming }; + } + validateGenerateOptions(normalized); + const naming = normalizeNaming(normalized); + return naming === undefined ? normalized : { ...normalized, naming }; +} + +module.exports = { + GENERATE_OPTION_KEYS, + VALID_EMIT, + VALID_CASES, + DEFAULT_EMIT, + normalizeNamingEntry, + normalizeNamingValue, + validateGenerateOptions, + upgradeError, + prepareOptions, +}; diff --git a/native.js b/native.js new file mode 100644 index 0000000..e197929 --- /dev/null +++ b/native.js @@ -0,0 +1,596 @@ +// prettier-ignore +/* eslint-disable */ +// @ts-nocheck +/* auto-generated by NAPI-RS */ + +const { readFileSync } = require('node:fs') +let nativeBinding = null +const loadErrors = [] + +const isMusl = () => { + let musl = false + if (process.platform === 'linux') { + musl = isMuslFromFilesystem() + if (musl === null) { + musl = isMuslFromReport() + } + if (musl === null) { + musl = isMuslFromChildProcess() + } + } + return musl +} + +const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') + +const isMuslFromFilesystem = () => { + try { + return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') + } catch { + return null + } +} + +const isMuslFromReport = () => { + let report = null + if (typeof process.report?.getReport === 'function') { + process.report.excludeNetwork = true + report = process.report.getReport() + } + if (!report) { + return null + } + if (report.header && report.header.glibcVersionRuntime) { + return false + } + if (Array.isArray(report.sharedObjects)) { + if (report.sharedObjects.some(isFileMusl)) { + return true + } + } + return false +} + +const isMuslFromChildProcess = () => { + try { + return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') + } catch (e) { + // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false + return false + } +} + +function requireNative() { + if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { + try { + return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + } catch (err) { + loadErrors.push(err) + } + } else if (process.platform === 'android') { + if (process.arch === 'arm64') { + try { + return require('./openapi-ng.android-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-android-arm64') + const bindingPackageVersion = require('@avsystem/openapi-ng-android-arm64/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./openapi-ng.android-arm-eabi.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-android-arm-eabi') + const bindingPackageVersion = require('@avsystem/openapi-ng-android-arm-eabi/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) + } + } else if (process.platform === 'win32') { + if (process.arch === 'x64') { + if (process.config?.variables?.shlib_suffix === 'dll.a' || process.config?.variables?.node_target_type === 'shared_library') { + try { + return require('./openapi-ng.win32-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-win32-x64-gnu') + const bindingPackageVersion = require('@avsystem/openapi-ng-win32-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./openapi-ng.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-win32-x64-msvc') + const bindingPackageVersion = require('@avsystem/openapi-ng-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ia32') { + try { + return require('./openapi-ng.win32-ia32-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-win32-ia32-msvc') + const bindingPackageVersion = require('@avsystem/openapi-ng-win32-ia32-msvc/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./openapi-ng.win32-arm64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-win32-arm64-msvc') + const bindingPackageVersion = require('@avsystem/openapi-ng-win32-arm64-msvc/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) + } + } else if (process.platform === 'darwin') { + try { + return require('./openapi-ng.darwin-universal.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-darwin-universal') + const bindingPackageVersion = require('@avsystem/openapi-ng-darwin-universal/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + if (process.arch === 'x64') { + try { + return require('./openapi-ng.darwin-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-darwin-x64') + const bindingPackageVersion = require('@avsystem/openapi-ng-darwin-x64/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./openapi-ng.darwin-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-darwin-arm64') + const bindingPackageVersion = require('@avsystem/openapi-ng-darwin-arm64/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) + } + } else if (process.platform === 'freebsd') { + if (process.arch === 'x64') { + try { + return require('./openapi-ng.freebsd-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-freebsd-x64') + const bindingPackageVersion = require('@avsystem/openapi-ng-freebsd-x64/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./openapi-ng.freebsd-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-freebsd-arm64') + const bindingPackageVersion = require('@avsystem/openapi-ng-freebsd-arm64/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) + } + } else if (process.platform === 'linux') { + if (process.arch === 'x64') { + if (isMusl()) { + try { + return require('./openapi-ng.linux-x64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-x64-musl') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-x64-musl/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./openapi-ng.linux-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-x64-gnu') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm64') { + if (isMusl()) { + try { + return require('./openapi-ng.linux-arm64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-arm64-musl') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm64-musl/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./openapi-ng.linux-arm64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-arm64-gnu') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm') { + if (isMusl()) { + try { + return require('./openapi-ng.linux-arm-musleabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-arm-musleabihf') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm-musleabihf/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./openapi-ng.linux-arm-gnueabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-arm-gnueabihf') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-arm-gnueabihf/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'loong64') { + if (isMusl()) { + try { + return require('./openapi-ng.linux-loong64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-loong64-musl') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-loong64-musl/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./openapi-ng.linux-loong64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-loong64-gnu') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-loong64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'riscv64') { + if (isMusl()) { + try { + return require('./openapi-ng.linux-riscv64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-riscv64-musl') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-riscv64-musl/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./openapi-ng.linux-riscv64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-riscv64-gnu') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-riscv64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ppc64') { + try { + return require('./openapi-ng.linux-ppc64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-ppc64-gnu') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-ppc64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 's390x') { + try { + return require('./openapi-ng.linux-s390x-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-linux-s390x-gnu') + const bindingPackageVersion = require('@avsystem/openapi-ng-linux-s390x-gnu/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) + } + } else if (process.platform === 'openharmony') { + if (process.arch === 'arm64') { + try { + return require('./openapi-ng.openharmony-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-openharmony-arm64') + const bindingPackageVersion = require('@avsystem/openapi-ng-openharmony-arm64/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'x64') { + try { + return require('./openapi-ng.openharmony-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-openharmony-x64') + const bindingPackageVersion = require('@avsystem/openapi-ng-openharmony-x64/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./openapi-ng.openharmony-arm.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@avsystem/openapi-ng-openharmony-arm') + const bindingPackageVersion = require('@avsystem/openapi-ng-openharmony-arm/package.json').version + if (bindingPackageVersion !== '0.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) + } + } else { + loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) + } +} + +nativeBinding = requireNative() + +if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + let wasiBinding = null + let wasiBindingError = null + try { + wasiBinding = require('./openapi-ng.wasi.cjs') + nativeBinding = wasiBinding + } catch (err) { + if (process.env.NAPI_RS_FORCE_WASI) { + wasiBindingError = err + } + } + if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + try { + wasiBinding = require('@avsystem/openapi-ng-wasm32-wasi') + nativeBinding = wasiBinding + } catch (err) { + if (process.env.NAPI_RS_FORCE_WASI) { + if (!wasiBindingError) { + wasiBindingError = err + } else { + wasiBindingError.cause = err + } + loadErrors.push(err) + } + } + } + if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) { + const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error') + error.cause = wasiBindingError + throw error + } +} + +const __OPENAPI_NG_PLATFORM_KEY__ = process.platform + '/' + process.arch; +const __OPENAPI_NG_SUPPORTED__ = new Set([ + 'darwin/x64', 'darwin/arm64', + 'linux/x64', 'linux/arm64', + 'win32/x64', 'win32/arm64', +]); +if (!nativeBinding && !__OPENAPI_NG_SUPPORTED__.has(__OPENAPI_NG_PLATFORM_KEY__)) { + throw new Error( + 'openapi-ng does not ship a native binary for ' + __OPENAPI_NG_PLATFORM_KEY__ + '. ' + + 'Supported platforms: ' + [...__OPENAPI_NG_SUPPORTED__].sort().join(', ') + '. ' + + 'If you need this platform, please file an issue.', + ); +} + +if (!nativeBinding) { + if (loadErrors.length > 0) { + throw new Error( + `Cannot find native binding. ` + + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', + { + cause: loadErrors.reduce((err, cur) => { + cur.cause = err + return cur + }), + }, + ) + } + throw new Error(`Failed to load native binding`) +} + +module.exports = nativeBinding +module.exports.EmitTarget = nativeBinding.EmitTarget +module.exports.generate = nativeBinding.generate +module.exports.InputFormat = nativeBinding.InputFormat +module.exports.ResponseType = nativeBinding.ResponseType diff --git a/package.json b/package.json new file mode 100644 index 0000000..d2ffc61 --- /dev/null +++ b/package.json @@ -0,0 +1,145 @@ +{ + "name": "@avsystem/openapi-ng", + "version": "0.0.0", + "description": "CLI-first Node package for generating TypeScript and Angular artifacts from a supported OpenAPI 3.x subset.", + "keywords": [ + "angular", + "codegen", + "napi-rs", + "openapi", + "typescript" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/AVSystem/openapi-ng.git" + }, + "bin": { + "openapi-ng": "bin/openapi-ng.js" + }, + "files": [ + "bin/openapi-ng.js", + "bin/lib/parse.js", + "lib/index.js", + "lib/config.js", + "lib/fetch-input.js", + "lib/generate-error.js", + "lib/wrapper-core.js", + "lib/error-marker.json", + "index.d.ts", + "native.js", + "browser.js" + ], + "main": "lib/index.js", + "browser": "browser.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "browser": "./browser.js", + "node": "./lib/index.js", + "default": "./lib/index.js" + }, + "./config": { + "types": "./index.d.ts", + "default": "./lib/config.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "artifacts": "napi artifacts", + "bench": "node --import @oxc-node/core/register benchmark/bench.ts", + "build": "napi build --platform --release --js native.js", + "postbuild": "node scripts/patch-types.mjs", + "build:debug": "napi build --platform --js native.js", + "format": "run-p format:oxfmt format:rs format:toml", + "format:oxfmt": "oxfmt . --write", + "format:toml": "taplo format", + "format:rs": "cargo fmt", + "lint": "oxlint", + "prepublishOnly": "node scripts/check-version-not-placeholder.mjs && napi prepublish -t npm", + "regen-snapshots": "node scripts/regen-snapshots.mjs", + "test": "ava", + "version": "napi version" + }, + "dependencies": { + "yaml": "^2.8.3" + }, + "devDependencies": { + "@angular/common": "21.2.7", + "@angular/core": "21.2.7", + "@napi-rs/cli": "3.6.0", + "@oxc-node/core": "0.1.0", + "@taplo/cli": "0.7.0", + "@types/node": "25.5.0", + "ava": "7.0.0", + "husky": "9.1.7", + "lint-staged": "16.4.0", + "npm-run-all2": "8.0.4", + "oxfmt": "0.43.0", + "oxlint": "1.58.0", + "rxjs": "7.8.2", + "tinybench": "6.0.0", + "typescript": "6.0.2" + }, + "peerDependencies": { + "@angular/common": ">=20.0.0", + "@angular/core": ">=20.0.0", + "rxjs": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@angular/common": { + "optional": true + }, + "@angular/core": { + "optional": true + }, + "rxjs": { + "optional": true + } + }, + "lint-staged": { + "*.@(js|ts|tsx)": [ + "oxlint --fix" + ], + "*.@(js|ts|tsx|yml|yaml|md|json|rs)": [ + "oxfmt --write" + ], + "*.toml": [ + "taplo format" + ] + }, + "napi": { + "binaryName": "openapi-ng", + "targets": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "aarch64-unknown-linux-gnu", + "aarch64-pc-windows-msvc" + ] + }, + "ava": { + "environmentVariables": { + "TS_NODE_PROJECT": "./tsconfig.json" + }, + "extensions": { + "ts": "module" + }, + "nodeArguments": [ + "--import", + "@oxc-node/core/register" + ], + "timeout": "2m", + "workerThreads": false + }, + "engines": { + "node": ">=18.0.0" + }, + "packageManager": "pnpm@10.33.3" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..01190ea --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,7660 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + tar@<7.5.15: 7.5.15 + glob@<13: 13.0.6 + +importers: + + .: + dependencies: + yaml: + specifier: ^2.8.3 + version: 2.8.3 + devDependencies: + '@angular/common': + specifier: 21.2.7 + version: 21.2.7(@angular/core@21.2.7(rxjs@7.8.2))(rxjs@7.8.2) + '@angular/core': + specifier: 21.2.7 + version: 21.2.7(rxjs@7.8.2) + '@napi-rs/cli': + specifier: 3.6.0 + version: 3.6.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.0) + '@oxc-node/core': + specifier: 0.1.0 + version: 0.1.0 + '@taplo/cli': + specifier: 0.7.0 + version: 0.7.0 + '@types/node': + specifier: 25.5.0 + version: 25.5.0 + ava: + specifier: 7.0.0 + version: 7.0.0(rollup@4.60.4) + husky: + specifier: 9.1.7 + version: 9.1.7 + lint-staged: + specifier: 16.4.0 + version: 16.4.0 + npm-run-all2: + specifier: 8.0.4 + version: 8.0.4 + oxfmt: + specifier: 0.43.0 + version: 0.43.0 + oxlint: + specifier: 1.58.0 + version: 1.58.0 + rxjs: + specifier: 7.8.2 + version: 7.8.2 + tinybench: + specifier: 6.0.0 + version: 6.0.0 + typescript: + specifier: 6.0.2 + version: 6.0.2 + + __test__/angular-consumer: + dependencies: + '@angular/common': + specifier: 21.2.5 + version: 21.2.5(@angular/core@21.2.5(rxjs@7.8.2))(rxjs@7.8.2) + '@angular/core': + specifier: 21.2.5 + version: 21.2.5(rxjs@7.8.2) + rxjs: + specifier: 7.8.2 + version: 7.8.2 + + website: + dependencies: + '@astrojs/starlight': + specifier: 0.39.2 + version: 0.39.2(astro@6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3))(typescript@6.0.2) + astro: + specifier: 6.3.6 + version: 6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3) + sharp: + specifier: 0.34.5 + version: 0.34.5 + devDependencies: + wrangler: + specifier: 4.93.1 + version: 4.93.1 + +packages: + + '@angular/common@21.2.5': + resolution: {integrity: sha512-MTjCbsHBkF9W12CW9yYiTJdVfZv/qCqBCZ2iqhMpDA5G+ZJiTKP0IDTJVrx2N5iHfiJ1lnK719t/9GXROtEAvg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/core': 21.2.5 + rxjs: ^6.5.3 || ^7.4.0 + + '@angular/common@21.2.7': + resolution: {integrity: sha512-YFdnU5z8JloJjLYa52OyCOULQhqEE/ym7vKfABySWDsiVXZr9FNmKMeZi/lUcg7ZO22UbBihqW9a9D6VSHOo+g==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/core': 21.2.7 + rxjs: ^6.5.3 || ^7.4.0 + + '@angular/core@21.2.5': + resolution: {integrity: sha512-JgHU134Adb1wrpyGC9ozcv3hiRAgaFTvJFn1u9OU/AVXyxu4meMmVh2hp5QhAvPnv8XQdKWWIkAY+dbpPE6zKA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/compiler': 21.2.5 + rxjs: ^6.5.3 || ^7.4.0 + zone.js: ~0.15.0 || ~0.16.0 + peerDependenciesMeta: + '@angular/compiler': + optional: true + zone.js: + optional: true + + '@angular/core@21.2.7': + resolution: {integrity: sha512-4bnskeRNNOZMn3buVw47Zz9Py4B8AZgYHe5xBEMOY5/yrldb7OFje5gWCWls23P18FKwhl+Xx1hgnOEPSs29gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/compiler': 21.2.7 + rxjs: ^6.5.3 || ^7.4.0 + zone.js: ~0.15.0 || ~0.16.0 + peerDependenciesMeta: + '@angular/compiler': + optional: true + zone.js: + optional: true + + '@astrojs/compiler@4.0.0': + resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==} + + '@astrojs/internal-helpers@0.9.1': + resolution: {integrity: sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==} + + '@astrojs/markdown-remark@7.1.2': + resolution: {integrity: sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==} + + '@astrojs/mdx@5.0.6': + resolution: {integrity: sha512-4dKe0ZMmqujofPNDHahzClkwinn9f8jHPcaXcgdGvPAlboD2mjzkUCofli2cBnxYAkdfhC6d50gBJ8i/cH8gHw==} + engines: {node: '>=22.12.0'} + peerDependencies: + astro: ^6.0.0 + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/sitemap@3.7.2': + resolution: {integrity: sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==} + + '@astrojs/starlight@0.39.2': + resolution: {integrity: sha512-vlw+bwnjtf5buCTUtLU7JfV6D3knslxqnspr6LKs6hfRuFZiyr5hT44F7GyDqR9FKANUqFxnIzWM81F1k/kOUA==} + peerDependencies: + astro: ^6.0.0 + + '@astrojs/telemetry@3.3.2': + resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@capsizecss/unpack@4.0.0': + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260520.1': + resolution: {integrity: sha512-7ilR8QUWpFO2RdulPuYkrwRYZxi7iuX8+11G9z97bdS7wCSFuetBsgsr2ISK7l/qzCiG5zshnfIwcdlYWD01Ag==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260520.1': + resolution: {integrity: sha512-6LVIEI0Tx3hfcXiEM+eHsIQVsOz1IAAoAMMsRlrx+7YaNiuHMib7yWfyzAlZPhiAg1BgiS5oB8iDn7Ws1amG+Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260520.1': + resolution: {integrity: sha512-7oV9YK7o63aiHnpBeKlxOIA3nK4sKxuwhnklCwJFb0LirkSemSG1LQlVvtVInoWSYDCTCoUeGkiPsS8WsZqcEw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260520.1': + resolution: {integrity: sha512-hzc/UKzw1/z+iTptBZVX7XYZTmJXsgn6RC9uKtQGUQxENxkoO1teba8qxVlKZT0AbPCQs5rg63Lsk0/eQhteqQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260520.1': + resolution: {integrity: sha512-BjMBhXqlEPaVc68tXihFI+YcU/5T4Jmj4ELDJaEa6NuCcbUMORESgO4OJZIDS4+rC2Lkv37telOktQxEOkqY3g==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expressive-code/core@0.42.0': + resolution: {integrity: sha512-MN11+9nfmaC7sYu2BZJXAXqwkBRt8t1xTSqP+Ti1NfTEskgl6xUnzDxoaiQkg0BMzpglA0pys4dpDKquP/cyIw==} + + '@expressive-code/plugin-frames@0.42.0': + resolution: {integrity: sha512-XtkPm+941Uta7Y+81Acv+OA/20F1NJmJhCX6UYGKpqEIGqplNh3PTOhcURp6tcruhlzJcWcvpWy6Oigz3SrjqA==} + + '@expressive-code/plugin-shiki@0.42.0': + resolution: {integrity: sha512-PMKey/kLmewttAHQezL+Y5Fx3vVssfDi3+FJOYQQS2mXP3tQspFELtKKAfsXfmSXdToZYgwoO69HJndqfE+09g==} + + '@expressive-code/plugin-text-markers@0.42.0': + resolution: {integrity: sha512-l59lUx8fq1v5g6SpmbDjiU0+7IdfbiWnAyRmtTVSpfhyq+nZMN4UcmYyu2b9Mynhzt7Gr+O+cXyEPDNb2AVWVQ==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@2.0.3': + resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/checkbox@5.1.0': + resolution: {integrity: sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.0.8': + resolution: {integrity: sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.1.5': + resolution: {integrity: sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.0.8': + resolution: {integrity: sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.0.8': + resolution: {integrity: sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@2.0.3': + resolution: {integrity: sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.3': + resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/input@5.0.8': + resolution: {integrity: sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.0.8': + resolution: {integrity: sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.8': + resolution: {integrity: sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.3.0': + resolution: {integrity: sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.2.4': + resolution: {integrity: sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.1.4': + resolution: {integrity: sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.1.0': + resolution: {integrity: sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.3': + resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@mapbox/node-pre-gyp@2.0.0': + resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==} + engines: {node: '>=18'} + hasBin: true + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@napi-rs/cli@3.6.0': + resolution: {integrity: sha512-aA8m4+9XxnK1+0sr4GplZP0Ze90gkzO8sMKaplOK0zXbLnzsLl6O2BQQt6rTCcTRzIN24wrrByakr/imM+CxhA==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + '@emnapi/runtime': ^1.7.1 + peerDependenciesMeta: + '@emnapi/runtime': + optional: true + + '@napi-rs/cross-toolchain@1.0.3': + resolution: {integrity: sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==} + peerDependencies: + '@napi-rs/cross-toolchain-arm64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-x86_64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-x86_64': ^1.0.3 + peerDependenciesMeta: + '@napi-rs/cross-toolchain-arm64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-arm64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-arm64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-arm64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-arm64-target-x86_64': + optional: true + '@napi-rs/cross-toolchain-x64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-x64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-x64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-x64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-x64-target-x86_64': + optional: true + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + resolution: {integrity: sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/lzma-android-arm64@1.4.5': + resolution: {integrity: sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/lzma-darwin-arm64@1.4.5': + resolution: {integrity: sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/lzma-darwin-x64@1.4.5': + resolution: {integrity: sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/lzma-freebsd-x64@1.4.5': + resolution: {integrity: sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + resolution: {integrity: sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + resolution: {integrity: sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + resolution: {integrity: sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + resolution: {integrity: sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + resolution: {integrity: sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + resolution: {integrity: sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + resolution: {integrity: sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + resolution: {integrity: sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-wasm32-wasi@1.4.5': + resolution: {integrity: sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + resolution: {integrity: sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + resolution: {integrity: sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + resolution: {integrity: sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma@1.4.5': + resolution: {integrity: sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==} + engines: {node: '>= 10'} + + '@napi-rs/tar-android-arm-eabi@1.1.0': + resolution: {integrity: sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/tar-android-arm64@1.1.0': + resolution: {integrity: sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/tar-darwin-arm64@1.1.0': + resolution: {integrity: sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/tar-darwin-x64@1.1.0': + resolution: {integrity: sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/tar-freebsd-x64@1.1.0': + resolution: {integrity: sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': + resolution: {integrity: sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/tar-linux-arm64-gnu@1.1.0': + resolution: {integrity: sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-arm64-musl@1.1.0': + resolution: {integrity: sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-linux-ppc64-gnu@1.1.0': + resolution: {integrity: sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-s390x-gnu@1.1.0': + resolution: {integrity: sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-gnu@1.1.0': + resolution: {integrity: sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-musl@1.1.0': + resolution: {integrity: sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-wasm32-wasi@1.1.0': + resolution: {integrity: sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/tar-win32-arm64-msvc@1.1.0': + resolution: {integrity: sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/tar-win32-ia32-msvc@1.1.0': + resolution: {integrity: sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/tar-win32-x64-msvc@1.1.0': + resolution: {integrity: sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/tar@1.1.0': + resolution: {integrity: sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': + resolution: {integrity: sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/wasm-tools-android-arm64@1.0.1': + resolution: {integrity: sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/wasm-tools-darwin-arm64@1.0.1': + resolution: {integrity: sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/wasm-tools-darwin-x64@1.0.1': + resolution: {integrity: sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/wasm-tools-freebsd-x64@1.0.1': + resolution: {integrity: sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': + resolution: {integrity: sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/wasm-tools@1.0.1': + resolution: {integrity: sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==} + engines: {node: '>= 10'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-node/core-android-arm-eabi@0.1.0': + resolution: {integrity: sha512-+ycNqMBKBz3EWpQKm7HgUMRLGKfFZsZ/JxN9ctx12CwGy0PTtjX3TB+1WEbiJrgWiZM0axBjuwe4MEqS6j1kgQ==} + cpu: [arm] + os: [android] + + '@oxc-node/core-android-arm64@0.1.0': + resolution: {integrity: sha512-sJZGDgQwlawrGnLPu1ueAoM/0sUKtCUZr0y4IljarPCVbVBHimcxKcyNlzucIjyQwwiptZourCbUNHONvm4i1g==} + cpu: [arm64] + os: [android] + + '@oxc-node/core-darwin-arm64@0.1.0': + resolution: {integrity: sha512-6hsKxbYCzAg390Y/URpCCDPDM4HSHl+Cxodsh2lw6GjX68FDFgLbEwOU2ivXfnXEmJMEMLSjv/0tPTBzDPIJJg==} + cpu: [arm64] + os: [darwin] + + '@oxc-node/core-darwin-x64@0.1.0': + resolution: {integrity: sha512-VCygvTqrquI3u25B0D6LjO1GnAVMyuAXae4PxRwmEwNuWgWaErG3zHaaU6+hBlXysiyZWKhSXAoAAJmrwe17tA==} + cpu: [x64] + os: [darwin] + + '@oxc-node/core-freebsd-x64@0.1.0': + resolution: {integrity: sha512-UQ0hCpwTOUpg1Oh/H/I0BRQmt8XCjCQArk3Cp0P1qc3/xcZ2IcTihxwIZAKLr/lkl+7Ya5rBn9zuY1fe28HaqA==} + cpu: [x64] + os: [freebsd] + + '@oxc-node/core-linux-arm-gnueabihf@0.1.0': + resolution: {integrity: sha512-Z+Il9u39MIhusioOQyRfZFMRY+e6QtePxRH44bUHXj7r+eTcHXTJyaDqqCr1HRvtPc9K/re1zH2hV9yEZtHUsg==} + cpu: [arm] + os: [linux] + + '@oxc-node/core-linux-arm64-gnu@0.1.0': + resolution: {integrity: sha512-nt2IT6MCZApyWsaEjky2znYZIII/BphuqD5mtnbGrFeF3dBpO6U2JiXHCQK/FZ/yrVLlCmcPgcOEL6yMYu8Tiw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-arm64-musl@0.1.0': + resolution: {integrity: sha512-0CYLp49qV4KIZmgckqiNcHiLROcb9J1sQSejIKJwbgcaoBLRw5olRCUE9cOi424jRxzXq50zsEv4ihheSA71pQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-node/core-linux-ppc64-gnu@0.1.0': + resolution: {integrity: sha512-eRarcfNvV0NorkUx5oywdUlC+E35dQCwZzI9BRe6ePywyeCBGJw6D5NnMdPfhCI1olPTkpPdCHXjY/gcl8XH3Q==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-s390x-gnu@0.1.0': + resolution: {integrity: sha512-PZx57NdmM9jq5jWXV1uk/PfHuDbodQ71KIwkNtLqLTdgJZS69m9Xi9j0vo6yyMBGJMD+aYK79eO0KxPU/5TgSA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-x64-gnu@0.1.0': + resolution: {integrity: sha512-N9lTK2chPpsXx0Ur6nUTW7OFE0d0wK4qpbb7WJAyJ48mU9bC22xuCi2yGDB748n16Yly3+mCJ+LiU7r3aBLZhA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-x64-musl@0.1.0': + resolution: {integrity: sha512-xs/ObZhHfN6AcAcjYQQkaeXBR1khm7ZlF6uOpmEhdAiG80P1aVw9ndEYz0LQHp2iVhPaRL9UfYuVKLqKah1TAA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-node/core-openharmony-arm64@0.1.0': + resolution: {integrity: sha512-B7ixIkc5G7pIqZmWM8oqi/qDdcRdo//mTNM4ChFNU1oou2Hy/FlidAKppMLFVRc4ddRrp6uEvYrT1LgZnjzn6g==} + cpu: [arm64] + os: [openharmony] + + '@oxc-node/core-wasm32-wasi@0.1.0': + resolution: {integrity: sha512-W4jM3S4XRgtpCKDGNGitCCfinIlyRAKoiQHi1nM+Nul3+Xf2RZqFOgBzdHxT+CahrT8kDi+p7gDGQN3uziwM1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-node/core-win32-arm64-msvc@0.1.0': + resolution: {integrity: sha512-3Qi2tdV+JSqK5Cfcpg3FX54zR78dBEFk+/usFXcF+bhMiBy8YmXGd+yDudl+wwEYf9Xz+Hqx74u8YCXrWDAUTg==} + cpu: [arm64] + os: [win32] + + '@oxc-node/core-win32-ia32-msvc@0.1.0': + resolution: {integrity: sha512-j66qK1qu5FcmfmIYdv1bp7gIuOi1LCDhzFOu7UdWZyFrinZHh7gHnLHngpSibM/M2Zp/mVLLWD2Ojlsy+8VtNA==} + cpu: [ia32] + os: [win32] + + '@oxc-node/core-win32-x64-msvc@0.1.0': + resolution: {integrity: sha512-S3qWlUQ7ZrOSLG1IwNiqQEovJU8MZ11Vc9k+NUzNTO20zFPEt9Jq1wFElusxJZBqBsd7AUXIq08n9dXC/ialbg==} + cpu: [x64] + os: [win32] + + '@oxc-node/core@0.1.0': + resolution: {integrity: sha512-Spk/ey3zg1CpBU1eUHBPbAbfFddntutZPPsweh+kNh9M9Ksc8j9OCujralW9HrVyi6nNWek1PnMfSZ7NPLLCKA==} + + '@oxfmt/binding-android-arm-eabi@0.43.0': + resolution: {integrity: sha512-CgU2s+/9hHZgo0IxVxrbMPrMj+tJ6VM3mD7Mr/4oiz4FNTISLoCvRmB5nk4wAAle045RtRjd86m673jwPyb1OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.43.0': + resolution: {integrity: sha512-T9OfRwjA/EdYxAqbvR7TtqLv5nIrwPXuCtTwOHtS7aR9uXyn74ZYgzgTo6/ZwvTq9DY4W+DsV09hB2EXgn9EbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.43.0': + resolution: {integrity: sha512-o3i49ZUSJWANzXMAAVY1wnqb65hn4JVzwlRQ5qfcwhRzIA8lGVaud31Q3by5ALHPrksp5QEaKCQF9aAS3TXpZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.43.0': + resolution: {integrity: sha512-vWECzzCFkb0kK6jaHjbtC5sC3adiNWtqawFCxhpvsWlzVeKmv5bNvkB4nux+o4JKWTpHCM57NDK/MeXt44txmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.43.0': + resolution: {integrity: sha512-rgz8JpkKiI/umOf7fl9gwKyQasC8bs5SYHy6g7e4SunfLBY3+8ATcD5caIg8KLGEtKFm5ujKaH8EfjcmnhzTLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': + resolution: {integrity: sha512-nWYnF3vIFzT4OM1qL/HSf1Yuj96aBuKWSaObXHSWliwAk2rcj7AWd6Lf7jowEBQMo4wCZVnueIGw/7C4u0KTBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.43.0': + resolution: {integrity: sha512-sFg+NWJbLfupYTF4WELHAPSnLPOn1jiDZ33Z1jfDnTaA+cC3iB35x0FMMZTFdFOz3icRIArncwCcemJFGXu6TQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.43.0': + resolution: {integrity: sha512-MelWqv68tX6wZEILDrTc9yewiGXe7im62+5x0bNXlCYFOZdA+VnYiJfAihbROsZ5fm90p9C3haFrqjj43XnlAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.43.0': + resolution: {integrity: sha512-ROaWfYh+6BSJ1Arwy5ujijTlwnZetxDxzBpDc1oBR4d7rfrPBqzeyjd5WOudowzQUgyavl2wEpzn1hw3jWcqLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.43.0': + resolution: {integrity: sha512-PJRs/uNxmFipJJ8+SyKHh7Y7VZIKQicqrrBzvfyM5CtKi8D7yZKTwUOZV3ffxmiC2e7l1SDJpkBEOyue5NAFsg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.43.0': + resolution: {integrity: sha512-j6biGAgzIhj+EtHXlbNumvwG7XqOIdiU4KgIWRXAEj/iUbHKukKW8eXa4MIwpQwW1YkxovduKtzEAPnjlnAhVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.43.0': + resolution: {integrity: sha512-RYWxAcslKxvy7yri24Xm9cmD0RiANaiEPs007EFG6l9h1ChM69Q5SOzACaCoz4Z9dEplnhhneeBaTWMEdpgIbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.43.0': + resolution: {integrity: sha512-DT6Q8zfQQy3jxpezAsBACEHNUUixKSYTwdXeXojNHe4DQOoxjPdjr3Szu6BRNjxLykZM/xMNmp9ElOIyDppwtw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.43.0': + resolution: {integrity: sha512-R8Yk7iYcuZORXmCfFZClqbDxRZgZ9/HEidUuBNdoX8Ptx07cMePnMVJ/woB84lFIDjh2ROHVaOP40Ds3rBXFqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.43.0': + resolution: {integrity: sha512-F2YYqyvnQNvi320RWZNAvsaWEHwmW3k4OwNJ1hZxRKXupY63expbBaNp6jAgvYs7y/g546vuQnGHQuCBhslhLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.43.0': + resolution: {integrity: sha512-OE6TdietLXV3F6c7pNIhx/9YC1/2YFwjU9DPc/fbjxIX19hNIaP1rS0cFjCGJlGX+cVJwIKWe8Mos+LdQ1yAJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.43.0': + resolution: {integrity: sha512-0nWK6a7pGkbdoypfVicmV9k/N1FwjPZENoqhlTU+5HhZnAhpIO3za30nEE33u6l6tuy9OVfpdXUqxUgZ+4lbZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.43.0': + resolution: {integrity: sha512-9aokTR4Ft+tRdvgN/pKzSkVy2ksc4/dCpDm9L/xFrbIw0yhLtASLbvoG/5WOTUh/BRPPnfGTsWznEqv0dlOmhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.43.0': + resolution: {integrity: sha512-4bPgdQux2ZLWn3bf2TTXXMHcJB4lenmuxrLqygPmvCJ104Yqzj1UctxSRzR31TiJ4MLaG22RK8dUsVpJtrCz5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.58.0': + resolution: {integrity: sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.58.0': + resolution: {integrity: sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.58.0': + resolution: {integrity: sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.58.0': + resolution: {integrity: sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.58.0': + resolution: {integrity: sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.58.0': + resolution: {integrity: sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.58.0': + resolution: {integrity: sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.58.0': + resolution: {integrity: sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.58.0': + resolution: {integrity: sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.58.0': + resolution: {integrity: sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.58.0': + resolution: {integrity: sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.58.0': + resolution: {integrity: sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.58.0': + resolution: {integrity: sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.58.0': + resolution: {integrity: sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.58.0': + resolution: {integrity: sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.58.0': + resolution: {integrity: sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.58.0': + resolution: {integrity: sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.58.0': + resolution: {integrity: sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.58.0': + resolution: {integrity: sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.5.2': + resolution: {integrity: sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==} + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rollup/pluginutils@5.1.4': + resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@shikijs/core@4.1.0': + resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.1.0': + resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.1.0': + resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.1.0': + resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.1.0': + resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.1.0': + resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} + engines: {node: '>=20'} + + '@shikijs/types@4.1.0': + resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.15': + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + + '@taplo/cli@0.7.0': + resolution: {integrity: sha512-Ck3zFhQhIhi02Hl6T4ZmJsXdnJE+wXcJz5f8klxd4keRYgenMnip3JDPMGDRLbnC/2iGd8P0sBIQqI3KxfVjBg==} + hasBin: true + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@vercel/nft@1.3.2': + resolution: {integrity: sha512-HC8venRc4Ya7vNeBsJneKHHMDDWpQie7VaKhAIOst3MKO+DES+Y/SbzSp8mFkD7OzwAE2HhHkeSuSmwS20mz3A==} + engines: {node: '>=20'} + hasBin: true + + abbrev@3.0.0: + resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==} + engines: {node: ^18.17.0 || >=20.5.0} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-find-index@1.0.2: + resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} + engines: {node: '>=0.10.0'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + arrgv@1.0.2: + resolution: {integrity: sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==} + engines: {node: '>=8.0.0'} + + arrify@3.0.0: + resolution: {integrity: sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==} + engines: {node: '>=12'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + astro-expressive-code@0.42.0: + resolution: {integrity: sha512-aiTePi2Cn0mJPYWZSzP1GcxCinX9mNtJyCCshVVPSg1yRwM7ADvFJOx0FnS440M9t65hp8JH//dc2qr22Bm4ag==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta + + astro@6.3.6: + resolution: {integrity: sha512-lM30gGI/iASK9Z1WQVnBBYzxVwDv8slkXbJOF7FNJdZQeBrFETpsQvYoLRupM/adt2ObP5hkYAWEeCjofoqlRw==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + ava@7.0.0: + resolution: {integrity: sha512-4sRJO/gehlfAgSbuH02mClDDiyymnuFmirE3KqPXl2pic1FaFTZaAACKqr85WT4o08iLjViMR9gmMkxzbZ3AgA==} + engines: {node: ^20.19 || ^22.20 || ^24.12 || >=25} + hasBin: true + peerDependencies: + '@ava/typescript': '*' + peerDependenciesMeta: + '@ava/typescript': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.0: + resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + blueimp-md5@2.19.0: + resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + callsites@4.2.0: + resolution: {integrity: sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==} + engines: {node: '>=12.20'} + + cbor@10.0.11: + resolution: {integrity: sha512-vIwORDd/WyB8Nc23o2zNN5RrtFGlR6Fca61TtjkUXueI3Jf2DOZDl1zsshvBntZ3wZHBM9ztjnkXSmzQDaq3WA==} + engines: {node: '>=20'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chunkd@2.0.1: + resolution: {integrity: sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + ci-parallel-vars@1.0.1: + resolution: {integrity: sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipanion@4.0.0-rc.4: + resolution: {integrity: sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==} + peerDependencies: + typanion: '*' + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + common-path-prefix@3.0.0: + resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + + concordance@5.0.4: + resolution: {integrity: sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==} + engines: {node: '>=10.18.0 <11 || >=12.14.0 <13 || >=14'} + + consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + currently-unhandled@0.4.1: + resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==} + engines: {node: '>=0.10.0'} + + date-time@3.1.0: + resolution: {integrity: sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==} + engines: {node: '>=6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emittery@1.2.0: + resolution: {integrity: sha512-KxdRyyFcS85pH3dnU8Y5yFUm2YJdaHwcBZWrfG8o89ZY9a13/f9itbN+YG3ELbBo9Pg5zvIozstmuV8bX13q6g==} + engines: {node: '>=14.16'} + + emnapi@1.9.2: + resolution: {integrity: sha512-OdUoQe/8so7FvubnE/DNV9sNNSFwDYQiK4ZCAz4agMnD1s6faLuDn2gzxfJrmMoKfxZhhsckqGNwqPnS5K140A==} + peerDependencies: + node-addon-api: '>= 6.1.0' + peerDependenciesMeta: + node-addon-api: + optional: true + + emoji-regex@10.3.0: + resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expressive-code@0.42.0: + resolution: {integrity: sha512-V5DtJLEKuj4wf9O6IRtPtRObkMVy2ggR+S0MdjrTw6m58krZnDioyhW1si3Y04c5YPeooP4nd85Yq9NwEVHS4g==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastq@1.16.0: + resolution: {integrity: sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up-simple@1.0.0: + resolution: {integrity: sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==} + engines: {node: '>=18'} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globby@16.1.1: + resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==} + engines: {node: '>=20'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + i18next@26.2.0: + resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore-by-default@2.1.0: + resolution: {integrity: sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==} + engines: {node: '>=10 <11 || >=12 <13 || >=14'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + irregular-plurals@4.2.0: + resolution: {integrity: sha512-bW9UXHL7bnUcNtTo+9ccSngbxc+V40H32IgvdVin0Xs8gbo+AVYD5g/72ce/54Kjfhq66vcZr8H8TKEvsifeOw==} + engines: {node: '>=18.20'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-unicode-supported@2.0.0: + resolution: {integrity: sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + js-string-escape@1.0.1: + resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==} + engines: {node: '>= 0.8'} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-parse-even-better-errors@4.0.0: + resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==} + engines: {node: ^18.17.0 || >=20.5.0} + + json-with-bigint@3.5.7: + resolution: {integrity: sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + load-json-file@7.0.1: + resolution: {integrity: sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + matcher@6.0.0: + resolution: {integrity: sha512-TzDerdcNtI79w7Av4GT57bLdElPA/VAkjqdMZv8yhuc8geU2z0ljW9anXbX/55aHEMTpYypZb1lxsA/46r9oOQ==} + engines: {node: '>=20'} + + md5-hex@3.0.1: + resolution: {integrity: sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==} + engines: {node: '>=8'} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + memoize@10.2.0: + resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} + engines: {node: '>=18'} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + miniflare@4.20260520.0: + resolution: {integrity: sha512-krgebvYME9k7CjxiveTzx89kAMeIstfK3KfTqtzLb/4mtLMD74KHtU009h/I0CTDSVIYtXm0JzJ40OtiVRGmOA==} + engines: {node: '>=22.0.0'} + hasBin: true + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.7.1: + resolution: {integrity: sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==} + hasBin: true + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + nofilter@3.1.0: + resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==} + engines: {node: '>=12.19'} + + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-normalize-package-bin@4.0.0: + resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-run-all2@8.0.4: + resolution: {integrity: sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==} + engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + oxfmt@0.43.0: + resolution: {integrity: sha512-KTYNG5ISfHSdmeZ25Xzb3qgz9EmQvkaGAxgBY/p38+ZiAet3uZeu7FnMwcSQJg152Qwl0wnYAxDc+Z/H6cvrwA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint@1.58.0: + resolution: {integrity: sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.18.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-config@5.0.0: + resolution: {integrity: sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==} + engines: {node: '>=18'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + plur@6.0.0: + resolution: {integrity: sha512-Y9wXQivjRX0REtwpA9+n0bYYypWESn3cWtW2vazymw711qn+AQXxzZjRqhANYGBLIMC1UzVdpwe/1hHQwHfwng==} + engines: {node: '>=20'} + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + read-package-json-fast@4.0.0: + resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} + engines: {node: ^18.17.0 || >=20.5.0} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.42.0: + resolution: {integrity: sha512-8rp/1YMEVVSYbtz+bFBx+uSx3vA4i4T8RwRm5Q/IWbucQnnQqQ0hDqtmKOr8tv+59Cik6cu5aH3WPo0I7csuTA==} + + rehype-format@5.0.1: + resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.1: + resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + + shiki@4.1.0: + resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} + engines: {node: '>=20'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stream-replace-string@2.0.0: + resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + supertap@3.0.1: + resolution: {integrity: sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + + temp-dir@3.0.0: + resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} + engines: {node: '>=14.16'} + + time-zone@1.0.0: + resolution: {integrity: sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==} + engines: {node: '>=4'} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@6.0.0: + resolution: {integrity: sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g==} + engines: {node: '>=20.0.0'} + + tinyclip@0.1.12: + resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.6.3: + resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + + typanion@3.14.0: + resolution: {integrity: sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universal-user-agent@7.0.2: + resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + well-known-symbols@2.0.0: + resolution: {integrity: sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==} + engines: {node: '>=6'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + workerd@1.20260520.1: + resolution: {integrity: sha512-mwW6H/NEKObeBVd0qkq91EGyOIC3TaNJBxp7kj5uChif/+qYD7nM5HE8ZYruwvEd15pRwUet+V8r21DCXCGDQQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.93.1: + resolution: {integrity: sha512-vV2GyKNWORysoJtryo45N2Tkk8wCnt/MTyW7wsevAAY+MiUArfJGvMiCb6SRB7pV5uWvEyIly1/rslehEjV32g==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260520.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@angular/common@21.2.5(@angular/core@21.2.5(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@angular/core': 21.2.5(rxjs@7.8.2) + rxjs: 7.8.2 + tslib: 2.6.3 + + '@angular/common@21.2.7(@angular/core@21.2.7(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@angular/core': 21.2.7(rxjs@7.8.2) + rxjs: 7.8.2 + tslib: 2.6.3 + + '@angular/core@21.2.5(rxjs@7.8.2)': + dependencies: + rxjs: 7.8.2 + tslib: 2.6.3 + + '@angular/core@21.2.7(rxjs@7.8.2)': + dependencies: + rxjs: 7.8.2 + tslib: 2.6.3 + + '@astrojs/compiler@4.0.0': {} + + '@astrojs/internal-helpers@0.9.1': + dependencies: + picomatch: 4.0.4 + + '@astrojs/markdown-remark@7.1.2': + dependencies: + '@astrojs/internal-helpers': 0.9.1 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + js-yaml: 4.1.1 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + retext-smartypants: 6.2.0 + shiki: 4.1.0 + smol-toml: 1.6.1 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/mdx@5.0.6(astro@6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3))': + dependencies: + '@astrojs/markdown-remark': 7.1.2 + '@mdx-js/mdx': 3.1.1 + acorn: 8.16.0 + astro: 6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3) + es-module-lexer: 2.1.0 + estree-util-visit: 2.0.0 + hast-util-to-html: 9.0.5 + piccolore: 0.1.3 + rehype-raw: 7.0.0 + remark-gfm: 4.0.1 + remark-smartypants: 3.0.2 + source-map: 0.7.6 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.2': + dependencies: + sitemap: 9.0.1 + stream-replace-string: 2.0.0 + zod: 4.4.3 + + '@astrojs/starlight@0.39.2(astro@6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3))(typescript@6.0.2)': + dependencies: + '@astrojs/markdown-remark': 7.1.2 + '@astrojs/mdx': 5.0.6(astro@6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3)) + '@astrojs/sitemap': 3.7.2 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.4 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3) + astro-expressive-code: 0.42.0(astro@6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3)) + bcp-47: 2.1.0 + hast-util-from-html: 2.0.3 + hast-util-select: 6.0.4 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.2.0(typescript@6.0.2) + js-yaml: 4.1.1 + klona: 2.0.6 + magic-string: 0.30.21 + mdast-util-directive: 3.1.0 + mdast-util-to-markdown: 2.1.2 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + rehype: 13.0.2 + rehype-format: 5.0.1 + remark-directive: 4.0.0 + ultrahtml: 1.6.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@astrojs/telemetry@3.3.2': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260520.1 + + '@cloudflare/workerd-darwin-64@1.20260520.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260520.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260520.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260520.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260520.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@ctrl/tinycolor@4.2.0': {} + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.6.3 + optional: true + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.6.3 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.6.3 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.6.3 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.6.3 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.6.3 + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@expressive-code/core@0.42.0': + dependencies: + '@ctrl/tinycolor': 4.2.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.15 + postcss-nested: 6.2.0(postcss@8.5.15) + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.42.0': + dependencies: + '@expressive-code/core': 0.42.0 + + '@expressive-code/plugin-shiki@0.42.0': + dependencies: + '@expressive-code/core': 0.42.0 + shiki: 4.1.0 + + '@expressive-code/plugin-text-markers@0.42.0': + dependencies: + '@expressive-code/core': 0.42.0 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@inquirer/ansi@2.0.3': {} + + '@inquirer/checkbox@5.1.0(@types/node@25.5.0)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/confirm@6.0.8(@types/node@25.5.0)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/core@11.1.5(@types/node@25.5.0)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.5.0) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/editor@5.0.8(@types/node@25.5.0)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/external-editor': 2.0.3(@types/node@25.5.0) + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/expand@5.0.8(@types/node@25.5.0)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/external-editor@2.0.3(@types/node@25.5.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/figures@2.0.3': {} + + '@inquirer/input@5.0.8(@types/node@25.5.0)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/number@4.0.8(@types/node@25.5.0)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/password@5.0.8(@types/node@25.5.0)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/prompts@8.3.0(@types/node@25.5.0)': + dependencies: + '@inquirer/checkbox': 5.1.0(@types/node@25.5.0) + '@inquirer/confirm': 6.0.8(@types/node@25.5.0) + '@inquirer/editor': 5.0.8(@types/node@25.5.0) + '@inquirer/expand': 5.0.8(@types/node@25.5.0) + '@inquirer/input': 5.0.8(@types/node@25.5.0) + '@inquirer/number': 4.0.8(@types/node@25.5.0) + '@inquirer/password': 5.0.8(@types/node@25.5.0) + '@inquirer/rawlist': 5.2.4(@types/node@25.5.0) + '@inquirer/search': 4.1.4(@types/node@25.5.0) + '@inquirer/select': 5.1.0(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/rawlist@5.2.4(@types/node@25.5.0)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/search@4.1.4(@types/node@25.5.0)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/select@5.1.0(@types/node@25.5.0)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.5(@types/node@25.5.0) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/type@4.0.3(@types/node@25.5.0)': + optionalDependencies: + '@types/node': 25.5.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mapbox/node-pre-gyp@2.0.0': + dependencies: + consola: 3.4.0 + detect-libc: 2.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.7.2 + tar: 7.5.15 + transitivePeerDependencies: + - encoding + - supports-color + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + acorn: 8.16.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.16.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@napi-rs/cli@3.6.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.0)': + dependencies: + '@inquirer/prompts': 8.3.0(@types/node@25.5.0) + '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@octokit/rest': 22.0.1 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + colorette: 2.0.20 + emnapi: 1.9.2 + es-toolkit: 1.45.1 + js-yaml: 4.1.0 + obug: 2.1.1 + semver: 7.7.4 + typanion: 3.14.0 + optionalDependencies: + '@emnapi/runtime': 1.9.2 + transitivePeerDependencies: + - '@emnapi/core' + - '@napi-rs/cross-toolchain-arm64-target-aarch64' + - '@napi-rs/cross-toolchain-arm64-target-armv7' + - '@napi-rs/cross-toolchain-arm64-target-ppc64le' + - '@napi-rs/cross-toolchain-arm64-target-s390x' + - '@napi-rs/cross-toolchain-arm64-target-x86_64' + - '@napi-rs/cross-toolchain-x64-target-aarch64' + - '@napi-rs/cross-toolchain-x64-target-armv7' + - '@napi-rs/cross-toolchain-x64-target-ppc64le' + - '@napi-rs/cross-toolchain-x64-target-s390x' + - '@napi-rs/cross-toolchain-x64-target-x86_64' + - '@types/node' + - node-addon-api + - supports-color + + '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@napi-rs/lzma': 1.4.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/tar': 1.1.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + debug: 4.4.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - supports-color + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + optional: true + + '@napi-rs/lzma-android-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-x64@1.4.5': + optional: true + + '@napi-rs/lzma-freebsd-x64@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma@1.4.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.4.5 + '@napi-rs/lzma-android-arm64': 1.4.5 + '@napi-rs/lzma-darwin-arm64': 1.4.5 + '@napi-rs/lzma-darwin-x64': 1.4.5 + '@napi-rs/lzma-freebsd-x64': 1.4.5 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.4.5 + '@napi-rs/lzma-linux-arm64-gnu': 1.4.5 + '@napi-rs/lzma-linux-arm64-musl': 1.4.5 + '@napi-rs/lzma-linux-ppc64-gnu': 1.4.5 + '@napi-rs/lzma-linux-riscv64-gnu': 1.4.5 + '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-musl': 1.4.5 + '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 + '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 + '@napi-rs/lzma-win32-x64-msvc': 1.4.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@napi-rs/tar-android-arm-eabi@1.1.0': + optional: true + + '@napi-rs/tar-android-arm64@1.1.0': + optional: true + + '@napi-rs/tar-darwin-arm64@1.1.0': + optional: true + + '@napi-rs/tar-darwin-x64@1.1.0': + optional: true + + '@napi-rs/tar-freebsd-x64@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm64-musl@1.1.0': + optional: true + + '@napi-rs/tar-linux-ppc64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-s390x-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-x64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-x64-musl@1.1.0': + optional: true + + '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/tar-win32-arm64-msvc@1.1.0': + optional: true + + '@napi-rs/tar-win32-ia32-msvc@1.1.0': + optional: true + + '@napi-rs/tar-win32-x64-msvc@1.1.0': + optional: true + + '@napi-rs/tar@1.1.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + optionalDependencies: + '@napi-rs/tar-android-arm-eabi': 1.1.0 + '@napi-rs/tar-android-arm64': 1.1.0 + '@napi-rs/tar-darwin-arm64': 1.1.0 + '@napi-rs/tar-darwin-x64': 1.1.0 + '@napi-rs/tar-freebsd-x64': 1.1.0 + '@napi-rs/tar-linux-arm-gnueabihf': 1.1.0 + '@napi-rs/tar-linux-arm64-gnu': 1.1.0 + '@napi-rs/tar-linux-arm64-musl': 1.1.0 + '@napi-rs/tar-linux-ppc64-gnu': 1.1.0 + '@napi-rs/tar-linux-s390x-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-musl': 1.1.0 + '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/tar-win32-arm64-msvc': 1.1.0 + '@napi-rs/tar-win32-ia32-msvc': 1.1.0 + '@napi-rs/tar-win32-x64-msvc': 1.1.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': + optional: true + + '@napi-rs/wasm-tools-android-arm64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-darwin-arm64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-darwin-x64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-freebsd-x64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': + optional: true + + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools@1.0.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + optionalDependencies: + '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 + '@napi-rs/wasm-tools-android-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-x64': 1.0.1 + '@napi-rs/wasm-tools-freebsd-x64': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-musl': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-musl': 1.0.1 + '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-tools-win32-arm64-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.16.0 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.2 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.2 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.2 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.8': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.7 + universal-user-agent: 7.0.2 + + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@oslojs/encoding@1.1.0': {} + + '@oxc-node/core-android-arm-eabi@0.1.0': + optional: true + + '@oxc-node/core-android-arm64@0.1.0': + optional: true + + '@oxc-node/core-darwin-arm64@0.1.0': + optional: true + + '@oxc-node/core-darwin-x64@0.1.0': + optional: true + + '@oxc-node/core-freebsd-x64@0.1.0': + optional: true + + '@oxc-node/core-linux-arm-gnueabihf@0.1.0': + optional: true + + '@oxc-node/core-linux-arm64-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-arm64-musl@0.1.0': + optional: true + + '@oxc-node/core-linux-ppc64-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-s390x-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-x64-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-x64-musl@0.1.0': + optional: true + + '@oxc-node/core-openharmony-arm64@0.1.0': + optional: true + + '@oxc-node/core-wasm32-wasi@0.1.0': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optional: true + + '@oxc-node/core-win32-arm64-msvc@0.1.0': + optional: true + + '@oxc-node/core-win32-ia32-msvc@0.1.0': + optional: true + + '@oxc-node/core-win32-x64-msvc@0.1.0': + optional: true + + '@oxc-node/core@0.1.0': + dependencies: + pirates: 4.0.7 + optionalDependencies: + '@oxc-node/core-android-arm-eabi': 0.1.0 + '@oxc-node/core-android-arm64': 0.1.0 + '@oxc-node/core-darwin-arm64': 0.1.0 + '@oxc-node/core-darwin-x64': 0.1.0 + '@oxc-node/core-freebsd-x64': 0.1.0 + '@oxc-node/core-linux-arm-gnueabihf': 0.1.0 + '@oxc-node/core-linux-arm64-gnu': 0.1.0 + '@oxc-node/core-linux-arm64-musl': 0.1.0 + '@oxc-node/core-linux-ppc64-gnu': 0.1.0 + '@oxc-node/core-linux-s390x-gnu': 0.1.0 + '@oxc-node/core-linux-x64-gnu': 0.1.0 + '@oxc-node/core-linux-x64-musl': 0.1.0 + '@oxc-node/core-openharmony-arm64': 0.1.0 + '@oxc-node/core-wasm32-wasi': 0.1.0 + '@oxc-node/core-win32-arm64-msvc': 0.1.0 + '@oxc-node/core-win32-ia32-msvc': 0.1.0 + '@oxc-node/core-win32-x64-msvc': 0.1.0 + + '@oxfmt/binding-android-arm-eabi@0.43.0': + optional: true + + '@oxfmt/binding-android-arm64@0.43.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.43.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.43.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.43.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.43.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.43.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.43.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.43.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.43.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.43.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.43.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.58.0': + optional: true + + '@oxlint/binding-android-arm64@1.58.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.58.0': + optional: true + + '@oxlint/binding-darwin-x64@1.58.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.58.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.58.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.58.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.58.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.58.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.58.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.58.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.58.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.58.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.58.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.58.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.58.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.58.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.58.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.58.0': + optional: true + + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rollup/pluginutils@5.1.4(rollup@4.60.4)': + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.60.4 + + '@rollup/pluginutils@5.3.0(rollup@4.60.4)': + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.60.4 + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@shikijs/core@4.1.0': + dependencies: + '@shikijs/primitive': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/primitive@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/types@4.1.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sindresorhus/is@7.2.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@speed-highlight/core@1.2.15': {} + + '@taplo/cli@0.7.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.6.3 + optional: true + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.6 + + '@types/estree@1.0.6': {} + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.13': {} + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 25.5.0 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.1': {} + + '@vercel/nft@1.3.2(rollup@4.60.4)': + dependencies: + '@mapbox/node-pre-gyp': 2.0.0 + '@rollup/pluginutils': 5.1.4(rollup@4.60.4) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.7.1 + picomatch: 4.0.3 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + abbrev@3.0.0: {} + + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.3: {} + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-find-index@1.0.2: {} + + array-iterate@2.0.1: {} + + arrgv@1.0.2: {} + + arrify@3.0.0: {} + + astring@1.9.0: {} + + astro-expressive-code@0.42.0(astro@6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3)): + dependencies: + astro: 6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3) + rehype-expressive-code: 0.42.0 + + astro@6.3.6(@types/node@25.5.0)(rollup@4.60.4)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 4.0.0 + '@astrojs/internal-helpers': 0.9.1 + '@astrojs/markdown-remark': 7.1.2 + '@astrojs/telemetry': 3.3.2 + '@capsizecss/unpack': 4.0.0 + '@clack/prompts': 1.4.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 1.1.1 + devalue: 5.8.1 + diff: 8.0.4 + dset: 3.1.4 + es-module-lexer: 2.1.0 + esbuild: 0.27.7 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.1.1 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + magicast: 0.5.3 + mrmime: 2.0.1 + neotraverse: 0.6.18 + obug: 2.1.1 + p-limit: 7.3.0 + p-queue: 9.3.0 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 4.1.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyclip: 0.1.12 + tinyexec: 1.0.4 + tinyglobby: 0.2.16 + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5 + vfile: 6.0.3 + vite: 7.3.3(@types/node@25.5.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@7.3.3(@types/node@25.5.0)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - yaml + + async-sema@3.1.1: {} + + ava@7.0.0(rollup@4.60.4): + dependencies: + '@vercel/nft': 1.3.2(rollup@4.60.4) + acorn: 8.16.0 + acorn-walk: 8.3.5 + ansi-styles: 6.2.3 + arrgv: 1.0.2 + arrify: 3.0.0 + callsites: 4.2.0 + cbor: 10.0.11 + chalk: 5.6.2 + chunkd: 2.0.1 + ci-info: 4.4.0 + ci-parallel-vars: 1.0.1 + cli-truncate: 5.1.1 + code-excerpt: 4.0.0 + common-path-prefix: 3.0.0 + concordance: 5.0.4 + currently-unhandled: 0.4.1 + debug: 4.4.3 + emittery: 1.2.0 + figures: 6.1.0 + globby: 16.1.1 + ignore-by-default: 2.1.0 + indent-string: 5.0.0 + is-plain-object: 5.0.0 + is-promise: 4.0.0 + matcher: 6.0.0 + memoize: 10.2.0 + ms: 2.1.3 + p-map: 7.0.4 + package-config: 5.0.0 + picomatch: 4.0.3 + plur: 6.0.0 + pretty-ms: 9.3.0 + resolve-cwd: 3.0.0 + stack-utils: 2.0.6 + supertap: 3.0.1 + temp-dir: 3.0.0 + write-file-atomic: 7.0.1 + yargs: 18.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + balanced-match@4.0.4: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + before-after-hook@4.0.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + blake3-wasm@2.1.5: {} + + blueimp-md5@2.19.0: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + callsites@4.2.0: {} + + cbor@10.0.11: + dependencies: + nofilter: 3.1.0 + + ccount@2.0.1: {} + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chardet@2.1.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@3.0.0: {} + + chunkd@2.0.1: {} + + ci-info@4.4.0: {} + + ci-parallel-vars@1.0.1: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.0 + string-width: 8.2.0 + + cli-width@4.1.0: {} + + clipanion@4.0.0-rc.4(typanion@3.14.0): + dependencies: + typanion: 3.14.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.0 + + clsx@2.1.1: {} + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + + collapse-white-space@2.1.0: {} + + colorette@2.0.20: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + common-ancestor-path@2.0.0: {} + + common-path-prefix@3.0.0: {} + + concordance@5.0.4: + dependencies: + date-time: 3.1.0 + esutils: 2.0.3 + fast-diff: 1.3.0 + js-string-escape: 1.0.1 + lodash: 4.17.21 + md5-hex: 3.0.1 + semver: 7.7.2 + well-known-symbols: 2.0.0 + + consola@3.4.0: {} + + convert-to-spaces@2.0.1: {} + + cookie-es@1.2.3: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + currently-unhandled@0.4.1: + dependencies: + array-find-index: 1.0.2 + + date-time@3.1.0: + dependencies: + time-zone: 1.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.0.2: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + direction@2.0.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + emittery@1.2.0: {} + + emnapi@1.9.2: {} + + emoji-regex@10.3.0: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + environment@1.1.0: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.1.0: {} + + es-toolkit@1.45.1: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.16.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.1.1: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@5.0.0: {} + + esprima@4.0.1: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.6 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.6 + + esutils@2.0.3: {} + + eventemitter3@5.0.1: {} + + eventemitter3@5.0.4: {} + + expressive-code@0.42.0: + dependencies: + '@expressive-code/core': 0.42.0 + '@expressive-code/plugin-frames': 0.42.0 + '@expressive-code/plugin-shiki': 0.42.0 + '@expressive-code/plugin-text-markers': 0.42.0 + + extend@3.0.2: {} + + fast-content-type-parse@3.0.0: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.16.0: + dependencies: + reusify: 1.0.4 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.0.0 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up-simple@1.0.0: {} + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globby@16.1.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + graceful-fs@4.2.11: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + i18next@26.2.0(typescript@6.0.2): + optionalDependencies: + typescript: 6.0.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@2.1.0: {} + + ignore@7.0.5: {} + + indent-string@5.0.0: {} + + inline-style-parser@0.2.7: {} + + iron-webcrypto@1.2.1: {} + + irregular-plurals@4.2.0: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@3.0.0: {} + + is-docker@4.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.5.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-promise@4.0.0: {} + + is-unicode-supported@2.0.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + isexe@3.1.1: {} + + js-string-escape@1.0.1: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@4.0.0: {} + + json-with-bigint@3.5.7: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + klona@2.0.6: {} + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.3 + string-argv: 0.3.2 + tinyexec: 1.0.4 + yaml: 2.8.3 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.1.1 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + load-json-file@7.0.1: {} + + lodash@4.17.21: {} + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.0 + + longest-streak@3.1.0: {} + + lru-cache@11.2.6: {} + + lru-cache@11.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + + matcher@6.0.0: + dependencies: + escape-string-regexp: 5.0.0 + + md5-hex@3.0.1: + dependencies: + blueimp-md5: 2.19.0 + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + memoize@10.2.0: + dependencies: + mimic-function: 5.0.1 + + memorystream@0.3.1: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.6 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-function@5.0.1: {} + + miniflare@4.20260520.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260520.1 + ws: 8.20.1 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + mute-stream@3.0.0: {} + + nanoid@3.3.12: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.7.1: {} + + node-mock-http@1.0.4: {} + + nofilter@3.1.0: {} + + nopt@8.1.0: + dependencies: + abbrev: 3.0.0 + + normalize-path@3.0.0: {} + + npm-normalize-package-bin@4.0.0: {} + + npm-run-all2@8.0.4: + dependencies: + ansi-styles: 6.2.3 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + picomatch: 4.0.3 + pidtree: 0.6.0 + read-package-json-fast: 4.0.0 + shell-quote: 1.8.1 + which: 5.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.1.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + oxfmt@0.43.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.43.0 + '@oxfmt/binding-android-arm64': 0.43.0 + '@oxfmt/binding-darwin-arm64': 0.43.0 + '@oxfmt/binding-darwin-x64': 0.43.0 + '@oxfmt/binding-freebsd-x64': 0.43.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.43.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.43.0 + '@oxfmt/binding-linux-arm64-gnu': 0.43.0 + '@oxfmt/binding-linux-arm64-musl': 0.43.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.43.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.43.0 + '@oxfmt/binding-linux-riscv64-musl': 0.43.0 + '@oxfmt/binding-linux-s390x-gnu': 0.43.0 + '@oxfmt/binding-linux-x64-gnu': 0.43.0 + '@oxfmt/binding-linux-x64-musl': 0.43.0 + '@oxfmt/binding-openharmony-arm64': 0.43.0 + '@oxfmt/binding-win32-arm64-msvc': 0.43.0 + '@oxfmt/binding-win32-ia32-msvc': 0.43.0 + '@oxfmt/binding-win32-x64-msvc': 0.43.0 + + oxlint@1.58.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.58.0 + '@oxlint/binding-android-arm64': 1.58.0 + '@oxlint/binding-darwin-arm64': 1.58.0 + '@oxlint/binding-darwin-x64': 1.58.0 + '@oxlint/binding-freebsd-x64': 1.58.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.58.0 + '@oxlint/binding-linux-arm-musleabihf': 1.58.0 + '@oxlint/binding-linux-arm64-gnu': 1.58.0 + '@oxlint/binding-linux-arm64-musl': 1.58.0 + '@oxlint/binding-linux-ppc64-gnu': 1.58.0 + '@oxlint/binding-linux-riscv64-gnu': 1.58.0 + '@oxlint/binding-linux-riscv64-musl': 1.58.0 + '@oxlint/binding-linux-s390x-gnu': 1.58.0 + '@oxlint/binding-linux-x64-gnu': 1.58.0 + '@oxlint/binding-linux-x64-musl': 1.58.0 + '@oxlint/binding-openharmony-arm64': 1.58.0 + '@oxlint/binding-win32-arm64-msvc': 1.58.0 + '@oxlint/binding-win32-ia32-msvc': 1.58.0 + '@oxlint/binding-win32-x64-msvc': 1.58.0 + + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + + p-map@7.0.4: {} + + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-config@5.0.0: + dependencies: + find-up-simple: 1.0.0 + load-json-file: 7.0.1 + + package-manager-detector@1.6.0: {} + + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse-ms@4.0.0: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + picomatch@4.0.4: {} + + pidtree@0.6.0: {} + + pirates@4.0.7: {} + + plur@6.0.0: + dependencies: + irregular-plurals: 4.2.0 + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prismjs@1.30.0: {} + + property-information@7.1.0: {} + + queue-microtask@1.2.3: {} + + radix3@1.1.2: {} + + read-package-json-fast@4.0.0: + dependencies: + json-parse-even-better-errors: 4.0.0 + npm-normalize-package-bin: 4.0.0 + + readdirp@5.0.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.6 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.42.0: + dependencies: + expressive-code: 0.42.0 + + rehype-format@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-format: 1.1.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.6.3 + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + semver@7.7.2: {} + + semver@7.7.4: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.1: {} + + shiki@4.1.0: + dependencies: + '@shikijs/core': 4.1.0 + '@shikijs/engine-javascript': 4.1.0 + '@shikijs/engine-oniguruma': 4.1.0 + '@shikijs/langs': 4.1.0 + '@shikijs/themes': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.12.4 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.0 + + slash@5.1.0: {} + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.0.0 + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stream-replace-string@2.0.0: {} + + string-argv@0.3.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.3.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + supertap@3.0.1: + dependencies: + indent-string: 5.0.0 + js-yaml: 3.14.1 + serialize-error: 7.0.1 + strip-ansi: 7.2.0 + + supports-color@10.2.2: {} + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-dir@3.0.0: {} + + time-zone@1.0.0: {} + + tiny-inflate@1.0.3: {} + + tinybench@6.0.0: {} + + tinyclip@0.1.12: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.6.3: {} + + typanion@3.14.0: {} + + type-fest@0.13.1: {} + + typescript@6.0.2: {} + + ufo@1.6.4: {} + + ultrahtml@1.6.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.16.0: {} + + undici-types@7.18.2: {} + + undici@7.24.8: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + unicorn-magic@0.4.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universal-user-agent@7.0.2: {} + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.0 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + util-deprecate@1.0.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.3(@types/node@25.5.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + yaml: 2.8.3 + + vitefu@1.1.3(vite@7.3.3(@types/node@25.5.0)(yaml@2.8.3)): + optionalDependencies: + vite: 7.3.3(@types/node@25.5.0)(yaml@2.8.3) + + web-namespaces@2.0.1: {} + + webidl-conversions@3.0.1: {} + + well-known-symbols@2.0.0: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-pm-runs@1.1.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.1 + + workerd@1.20260520.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260520.1 + '@cloudflare/workerd-darwin-arm64': 1.20260520.1 + '@cloudflare/workerd-linux-64': 1.20260520.1 + '@cloudflare/workerd-linux-arm64': 1.20260520.1 + '@cloudflare/workerd-windows-64': 1.20260520.1 + + wrangler@4.93.1: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260520.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260520.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + ws@8.20.1: {} + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yallist@5.0.0: {} + + yaml@2.8.3: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@1.2.2: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.15 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d478d86 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - 'website' + - '__test__/angular-consumer' + +allowBuilds: + esbuild: true + sharp: true + workerd: true + +overrides: + 'tar@<7.5.15': '7.5.15' + 'glob@<13': '13.0.6' diff --git a/proptest-regressions/plan/naming.txt b/proptest-regressions/plan/naming.txt new file mode 100644 index 0000000..e8aec35 --- /dev/null +++ b/proptest-regressions/plan/naming.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 5583af73bd1bddfc8f7aa83e50974ef29e3c4acbb3304357885f32d6e3c9ef53 # shrinks to tag = "Ⰰ" +cc 16255a0458979114a7abcc004a298c8d36cd06a91a2e34a68bcea9b560785045 # shrinks to name = "" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..b5f0026 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +unstable_features = true +tab_spaces = 2 +control_brace_style = "AlwaysSameLine" diff --git a/scripts/check-version-not-placeholder.mjs b/scripts/check-version-not-placeholder.mjs new file mode 100644 index 0000000..34ce8e2 --- /dev/null +++ b/scripts/check-version-not-placeholder.mjs @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8')); + +if (pkg.version === '0.0.0') { + console.error(`Refusing to publish: package.json version is "0.0.0" (placeholder).`); + console.error( + `Set a real version (e.g. via \`pnpm version \` then \`napi version\`).`, + ); + process.exit(1); +} diff --git a/scripts/patch-types.mjs b/scripts/patch-types.mjs new file mode 100644 index 0000000..dffef24 --- /dev/null +++ b/scripts/patch-types.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +// Post-processes the NAPI-RS-generated `index.d.ts`: +// 1. Narrows `code: string` to `code: DiagnosticCode` (named union) +// and `severity: string` to `severity: 'warning' | 'error'` so TS +// consumers get autocomplete and exhaustive switches. +// 2. Rewrites the `export declare const enum EmitTarget` block into a +// string-literal union plus an ambient frozen-const declaration. +// `const enum` in a published `.d.ts` is hostile to consumers +// compiling under `isolatedModules` / `verbatimModuleSyntax` (Vite, +// esbuild, Bun, TS 5.x defaults), so we replace the shape with one +// that survives single-file transpilation. +// 3. Concatenates the hand-authored tail file `index.d.ts.in`, which +// carries the `DiagnosticCode` union and the `GenerateError` class +// declaration (defined in `lib/index.js`, not by napi-rs). +// +// Run automatically by the `postbuild` npm script after `napi build`. + +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const dtsPath = path.join(repoRoot, 'index.d.ts'); +const tailPath = path.join(repoRoot, 'index.d.ts.in'); + +let content = fs.readFileSync(dtsPath, 'utf8'); +const tail = fs.readFileSync(tailPath, 'utf8'); + +// Strip any prior tail concatenation so reruns stay idempotent. The +// tail begins with the BEGIN marker comment. +const TAIL_BEGIN = '\n// Hand-authored tail'; +const beginIdx = content.indexOf(TAIL_BEGIN); +if (beginIdx !== -1) { + content = content.slice(0, beginIdx).trimEnd() + '\n'; +} + +/** + * Scope a set of literal text substitutions to the body of a named + * interface declaration. Catches drift (e.g. napi-rs changing indent, + * or another interface coincidentally containing `code: string`) early + * by failing loud when a target cannot be matched within the named + * block. + */ +function patchInterface(src, name, edits) { + const re = new RegExp(`(interface\\s+${name}\\s*\\{)([\\s\\S]*?)(^})`, 'm'); + const m = src.match(re); + if (!m) throw new Error(`interface ${name} not found`); + let body = m[2]; + for (const [from, to] of edits) { + if (!body.includes(from)) { + // Idempotent: already narrowed by a prior run. + if (body.includes(to)) continue; + throw new Error(`patch target not found in ${name}: ${from}`); + } + body = body.split(from).join(to); + } + return src.replace(re, `$1${body}$3`); +} + +// Narrow the napi-emitted opaque strings to typed unions. Same shape +// (`code: string`, `severity: string`) appears in multiple interfaces +// (GeneratorDiagnostic, GenerateErrorPayload) — we scope each set of +// substitutions to its own interface block so a stray `code: string` +// elsewhere can never silently corrupt the patch. +const diagnosticEdits = [ + [' code: string', ' code: DiagnosticCode'], + [' subcode?: string', ' subcode: DiagnosticSubcode | null'], + [' severity: string', " severity: 'warning' | 'error'"], +]; +const payloadEdits = [ + [' code: string', ' code: DiagnosticCode'], + [' subcode?: string', ' subcode: DiagnosticSubcode | null'], +]; + +content = patchInterface(content, 'GeneratorDiagnostic', diagnosticEdits); +content = patchInterface(content, 'GenerateErrorPayload', payloadEdits); + +// Replace the napi-rs-emitted `const enum EmitTarget` with a string-literal +// union plus an ambient frozen-const declaration. Consumers compiling under +// `isolatedModules` reject `const enum` imports across module boundaries +// (Vite, esbuild, Bun, TS 5.x defaults); the union+const pair preserves the +// `EmitTarget.Models` access shape while keeping the surface importable. +const ENUM_BLOCK_RE = + /export declare const enum EmitTarget \{\s*Models = 'models',\s*Angular = 'angular'\s*\}/; +const ENUM_REPLACEMENT = + "export type EmitTarget = 'models' | 'angular';\n" + + 'export declare const EmitTarget: {\n' + + " readonly Models: 'models';\n" + + " readonly Angular: 'angular';\n" + + '};'; +if (ENUM_BLOCK_RE.test(content)) { + content = content.replace(ENUM_BLOCK_RE, ENUM_REPLACEMENT); +} else if (!content.includes("export type EmitTarget = 'models' | 'angular';")) { + throw new Error( + 'EmitTarget const-enum block not found and union form not already present', + ); +} + +// Mark `GenerateOptions.emit` optional on the published surface. The JS +// wrapper in `lib/index.js` defaults the field to `['models', 'angular']` +// (mirroring the CLI's DEFAULT_EMIT in `bin/lib/parse.js`) before +// crossing the NAPI boundary, so consumers can omit it. Kept as a +// rewrite here — rather than annotating the Rust field as +// `Option>` — so the core `GenerateConfig::from` +// conversion (and its cargo-side tests) continue to receive a +// populated emit list with no extra None-handling. +if (content.includes('emit: Array')) { + content = content.replace('emit: Array', 'emit?: Array'); +} else if (!content.includes('emit?: Array')) { + throw new Error('patch-types: expected `emit: Array` in index.d.ts'); +} + +// Rewrite `GenerateOptions.naming` from the NAPI-boundary shape +// (`NamingOptions`) to the user-friendly shape (`NamingConfig`). +// Native `RegExp` does not cross the NAPI boundary as a JS object so +// the Rust side declares `NamingParseSpec { source, flags }`; the JS +// wrapper unpacks user `RegExp`s into that shape. Consumers should +// only ever see the friendly `NamingConfig` type on the published +// surface, not the wire shape. +if (content.includes('naming?: NamingOptions')) { + content = content.replace('naming?: NamingOptions', 'naming?: NamingConfig'); +} else if (!content.includes('naming?: NamingConfig')) { + throw new Error('patch-types: expected `naming?: NamingOptions` in index.d.ts'); +} + +// `generate` becomes async on the JS wrapper boundary: it always +// returns Promise. napi-rs emits a synchronous +// signature because the Rust generate fn itself is sync; the wrapper +// in lib/index.js adds the async semantics around URL fetching, so +// the published surface must reflect that. +if ( + content.includes( + 'export declare function generate(options: GenerateOptions): GenerateResult', + ) +) { + content = content.replace( + 'export declare function generate(options: GenerateOptions): GenerateResult', + 'export declare function generate(options: GenerateOptions): Promise', + ); +} else if ( + !content.includes( + 'export declare function generate(options: GenerateOptions): Promise', + ) +) { + throw new Error('patch-types: expected `generate(...): GenerateResult` in index.d.ts'); +} + +// `inputPath` becomes optional on the published surface because the +// caller may instead pass `inputContents` (validated mutually +// exclusive at runtime). napi-rs may emit it as either required or +// optional depending on whether the Rust field is Option at +// build time; both cases are handled here. +if (content.includes('inputPath: string')) { + content = content.replace('inputPath: string', 'inputPath?: string'); +} else if (!content.includes('inputPath?: string')) { + throw new Error( + 'patch-types: expected `inputPath: string` or `inputPath?: string` in index.d.ts', + ); +} + +content = content.trimEnd() + '\n\n' + tail.trimEnd() + '\n'; + +fs.writeFileSync(dtsPath, content); +console.log('patch-types: narrowed code/severity and appended tail to index.d.ts'); + +// --------------------------------------------------------------------------- +// Patch native.js: inject a friendly error for unsupported platforms BEFORE +// the generic npm-bug-report throw that NAPI-RS emits. +// --------------------------------------------------------------------------- +const nativePath = path.join(repoRoot, 'native.js'); +let nativeContent = fs.readFileSync(nativePath, 'utf8'); + +// Idempotency guard: skip if the injection is already present. +const INJECTION_GUARD = '__OPENAPI_NG_PLATFORM_KEY__'; +if (!nativeContent.includes(INJECTION_GUARD)) { + // The exact marker string that NAPI-RS emits; fail loud if it drifts. + const MARKER = 'if (!nativeBinding) {\n if (loadErrors.length > 0) {'; + const markerIdx = nativeContent.indexOf(MARKER); + if (markerIdx === -1) { + throw new Error( + 'patch-native-loader: cannot find expected marker in native.js — ' + + 'NAPI-RS may have changed its generated output; update the patch.', + ); + } + + const injection = + "const __OPENAPI_NG_PLATFORM_KEY__ = process.platform + '/' + process.arch;\n" + + 'const __OPENAPI_NG_SUPPORTED__ = new Set([\n' + + " 'darwin/x64', 'darwin/arm64',\n" + + " 'linux/x64', 'linux/arm64',\n" + + " 'win32/x64', 'win32/arm64',\n" + + ']);\n' + + 'if (!nativeBinding && !__OPENAPI_NG_SUPPORTED__.has(__OPENAPI_NG_PLATFORM_KEY__)) {\n' + + ' throw new Error(\n' + + " 'openapi-ng does not ship a native binary for ' + __OPENAPI_NG_PLATFORM_KEY__ + '. ' +\n" + + " 'Supported platforms: ' + [...__OPENAPI_NG_SUPPORTED__].sort().join(', ') + '. ' +\n" + + " 'If you need this platform, please file an issue.',\n" + + ' );\n' + + '}\n\n'; + + nativeContent = + nativeContent.slice(0, markerIdx) + injection + nativeContent.slice(markerIdx); + + fs.writeFileSync(nativePath, nativeContent); + console.log('patch-types: injected unsupported-platform error into native.js'); +} else { + console.log('patch-types: native.js already patched (idempotent skip)'); +} + +// --------------------------------------------------------------------------- +// Re-author browser.js. `napi build` writes a single-line `export *` stub +// that defeats our hand-authored entry. We overwrite unconditionally so +// the post-build state is canonical regardless of what napi-rs emitted. +// The browser entry is a hard-error stub — browser/edge runtimes are not +// supported, so `generate()` throws `E_UNSUPPORTED_RUNTIME` at call time. +// --------------------------------------------------------------------------- +const browserPath = path.join(repoRoot, 'browser.js'); + +const browserContent = `'use strict'; + +// Browser/edge entry point. openapi-ng requires the native binding to +// generate code, so any browser or edge runtime (Vite/Webpack/esbuild +// resolving the \`browser\` field, Cloudflare Workers, Vercel Edge, etc.) +// gets a stub that throws \`E_UNSUPPORTED_RUNTIME\` at call time. The +// module itself stays importable so bundlers don't choke at build time. + +const { GenerateError } = require('./lib/generate-error.js'); + +async function generate() { + throw new GenerateError({ + code: 'E_UNSUPPORTED_RUNTIME', + message: + 'openapi-ng does not support browser or edge runtimes. ' + + 'Run the generator from Node, or remove openapi-ng from your browser bundle.', + warnings: [], + }); +} + +// Frozen runtime shape for \`EmitTarget\`. Mirrors the ambient const +// declared in \`index.d.ts\` and matches the \`lib/index.js\` entry, so the +// surface a consumer destructures is identical across both runtimes. +const EmitTarget = Object.freeze({ + Models: 'models', + Angular: 'angular', +}); + +module.exports = { + generate, + GenerateError, + EmitTarget, +}; +`; + +const existingBrowser = fs.existsSync(browserPath) + ? fs.readFileSync(browserPath, 'utf8') + : null; +if (existingBrowser === browserContent) { + console.log('patch-types: browser.js already canonical (idempotent skip)'); +} else { + fs.writeFileSync(browserPath, browserContent); + console.log('patch-types: re-authored browser.js with hard-error stub'); +} diff --git a/scripts/regen-snapshots.mjs b/scripts/regen-snapshots.mjs new file mode 100644 index 0000000..8403823 --- /dev/null +++ b/scripts/regen-snapshots.mjs @@ -0,0 +1,381 @@ +#!/usr/bin/env node +// Regenerate __test__/snapshots/generate-native/*.{success,failure}.json by +// running each fixture through generate and writing the result. +// +// Run with: pnpm regen-snapshots +// +// Storage layout: +// - .success.json — summary + diagnostics + a path-only +// artifacts list (no inline contents). +// - / — each artifact's contents lives in a +// sibling file. PR diffs read as real +// TS instead of JSON-escaped strings. +// - static-template.json — path-only list for the angular static +// templates that are byte-identical +// across every success fixture. +// - static-template/ — sibling files for those templates. +// +// Both success and failure snapshots are regenerated. Use this script +// after any legitimate output change (emit format tweak, diagnostic +// message edit, planner reorder) — it is the single source of truth for +// the deep-equal snapshot tests in __test__/generate.snapshot.spec.ts. +// +// Snapshots that would NOT change are reported as "unchanged" and not +// rewritten — safe to run on a clean tree. + +import { generate } from '../lib/index.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const snapshotDir = path.join(repoRoot, '__test__/snapshots/generate-native'); + +// emit is required; artifacts are always returned with full contents. +const DEFAULT_OPTIONS = { + emit: ['models', 'angular'], +}; + +// Strip the per-version banner from artifact contents so snapshots +// survive version bumps. Keep this in sync with BANNER_RE in +// __test__/generate.snapshot.spec.ts. +const BANNER_RE = + /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; + +// Paths whose contents are byte-identical across every success fixture +// (the angular static-template artifacts). Each per-fixture sibling +// directory omits these files — they live once under +// __test__/snapshots/generate-native/static-template/. +const STATIC_TEMPLATE_PATHS = new Set(['rest.model.ts', 'rest.util.ts']); + +const STATIC_TEMPLATE_DIR = path.join(snapshotDir, 'static-template'); +const STATIC_TEMPLATE_INDEX = path.join(snapshotDir, 'static-template.json'); + +function failurePayload(err) { + return { + code: err.code, + message: err.message, + path: err.path ?? null, + warnings: err.warnings ?? [], + }; +} + +const successFixtures = [ + 'petstore-minimal.openapi.yaml', + 'petstore-minimal.openapi.json', + 'petstore-rich.openapi.yaml', + 'petstore-rich.openapi.json', + 'oneof-anyof-composition.openapi.yaml', + 'oneof-anyof-composition.openapi.json', + 'allof-composition.openapi.yaml', + 'additional-properties.openapi.yaml', + 'additional-properties-false.openapi.yaml', + 'recursive-model.openapi.yaml', + 'single-entry-composition.openapi.yaml', + 'empty-shapes.openapi.yaml', + 'inline-model.openapi.yaml', + 'nullable-optional.openapi.yaml', + 'header-param.openapi.yaml', + 'large-enum.openapi.yaml', + 'multi-tag-operation.openapi.yaml', + 'nullable-oneof.openapi.yaml', + 'security-schemes.openapi.yaml', + 'circular-allof.openapi.yaml', + 'discriminated-union.openapi.yaml', + 'bench-large.openapi.yaml', + 'reserved-prop-names.openapi.yaml', + 'jsdoc-descriptions.openapi.yaml', + 'multi-warning.openapi.yaml', + 'deprecated-fields.openapi.yaml', + 'recursive-oneof.openapi.yaml', + 'response-204-no-content.openapi.yaml', + 'response-octet-stream.openapi.yaml', + 'response-default-fallback.openapi.yaml', + 'string-formats.openapi.yaml', + 'discriminator-mapping.openapi.yaml', + 'discriminator-allof.openapi.yaml', + 'anchor-modest.openapi.yaml', + 'body-multipart-mixed-fields.openapi.yaml', + 'body-multipart-ref-to-named-object.openapi.yaml', + 'body-urlencoded-scalar-and-array.openapi.yaml', + 'response-blob-via-pdf.openapi.yaml', + 'response-text-via-text-plain.openapi.yaml', + 'response-problem-json.openapi.yaml', +]; + +// Failure fixtures: each entry maps a fixture file to the snapshot +// label. The label distinguishes failure scenarios from the same fixture +// (e.g. petstore-rich has both a success path and an invalid-mapped-type +// failure path). For most fixtures the label is just the fixture name. +const failureFixtures = [ + { + fixture: 'empty-parameter.openapi.yaml', + snapshot: 'empty-parameter.openapi.yaml.failure.json', + }, + { + fixture: 'inline-parameter.openapi.yaml', + snapshot: 'inline-parameter.openapi.yaml.failure.json', + }, + { + fixture: 'invalid-enum-type.openapi.yaml', + snapshot: 'invalid-enum-type.openapi.yaml.failure.json', + }, + { + fixture: 'invalid-enum-value.openapi.json', + snapshot: 'invalid-enum-value.openapi.json.failure.json', + }, + // malformed.yaml message wording depends on serde_yml line/column + // output — asserted by regex in generate.snapshot.spec.ts instead of + // pinned here. + { fixture: 'unsupported-root.yaml', snapshot: 'unsupported-root.yaml.failure.json' }, + { + fixture: 'unsupported-semantic.openapi.yaml', + snapshot: 'unsupported-semantic.openapi.yaml.failure.json', + }, + { + fixture: 'additional-properties-boolean.openapi.yaml', + snapshot: 'additional-properties-boolean.openapi.yaml.failure.json', + }, + { + fixture: 'external-ref.openapi.yaml', + snapshot: 'external-ref.openapi.yaml.failure.json', + }, + { + fixture: 'field-collision.openapi.yaml', + snapshot: 'field-collision.openapi.yaml.failure.json', + }, + // deep-nested-allof exercises the MAX_NORMALIZE_DEPTH guard at 40 + // levels of inline allOf nesting (well above the constant of 32, well + // below the serde recursion limit). Pins the unsupported-semantic + // diagnostic so a regression that removes the guard, raises the bound + // without regenerating, or shifts the error code surfaces here. + { + fixture: 'deep-nested-allof.openapi.yaml', + snapshot: 'deep-nested-allof.openapi.yaml.failure.json', + }, + // discriminator-missing-property exercises the + // narrow_discriminator_properties presence check. A oneOf member + // missing the declared discriminator property would otherwise be + // patched with a synthetic literal that never existed on the source + // schema. Pins the E_POLICY_VIOLATION + missing-discriminator-property + // subcode so the silent-miscompile regression surfaces here. + { + fixture: 'discriminator-missing-property.openapi.yaml', + snapshot: 'discriminator-missing-property.openapi.yaml.failure.json', + }, + // discriminator-mapping-external-ref exercises the + // resolve_discriminator validation: a mapping value that looks like a + // ref (contains `/`) but is not an internal `#/components/schemas/` + // ref must be rejected through the central `normalize_reference` + // path, instead of silently passing as a bare literal that never + // matches any union member. Pins the E_UNSUPPORTED_SEMANTIC + // diagnostic so a regression that bypasses the central resolver + // surfaces here. + { + fixture: 'discriminator-mapping-external-ref.openapi.yaml', + snapshot: 'discriminator-mapping-external-ref.openapi.yaml.failure.json', + }, + // unbalanced-path-template exercises the normalize-stage path string + // validation. Without the check the emit stage would silently produce + // a broken template (`url: `/pets/id`` with no `${encodeURIComponent}` + // expansion). Pins the rejection so a regression that removes the + // guard surfaces here. + { + fixture: 'unbalanced-path-template.openapi.yaml', + snapshot: 'unbalanced-path-template.openapi.yaml.failure.json', + }, + // anchor-fanout exercises the OPENAPI_NG_MAX_EXPANSION_RATIO guard: a + // ~45 KB source whose 500 schemas × 16 anchor aliases each + // re-serialise into ~15 MB of inlined node tree. Pins the + // mapping-expansion-exceeded subcode so a regression that removes the + // guard or shifts the cap surfaces here. + { + fixture: 'anchor-fanout.openapi.yaml', + snapshot: 'anchor-fanout.openapi.yaml.failure.json', + }, + // Policy-violation subcodes introduced in Phase 4 for multipart / + // urlencoded form bodies and unsupported content types. One fixture per + // subcode; each pins the diagnostic so a regression that renames a + // subcode or routes a reject path through a different arm surfaces here. + { + fixture: 'body-multi-content.openapi.yaml', + snapshot: 'body-multi-content.openapi.yaml.failure.json', + }, + { + fixture: 'body-content-type-xml.openapi.yaml', + snapshot: 'body-content-type-xml.openapi.yaml.failure.json', + }, + { + fixture: 'body-multipart-nested-object.openapi.yaml', + snapshot: 'body-multipart-nested-object.openapi.yaml.failure.json', + }, + { + fixture: 'body-multipart-composed-field.openapi.yaml', + snapshot: 'body-multipart-composed-field.openapi.yaml.failure.json', + }, + { + fixture: 'body-multipart-non-object.openapi.yaml', + snapshot: 'body-multipart-non-object.openapi.yaml.failure.json', + }, + { + fixture: 'body-multipart-open-schema.openapi.yaml', + snapshot: 'body-multipart-open-schema.openapi.yaml.failure.json', + }, + { + fixture: 'body-urlencoded-binary-field.openapi.yaml', + snapshot: 'body-urlencoded-binary-field.openapi.yaml.failure.json', + }, + { + fixture: 'body-urlencoded-nested-object.openapi.yaml', + snapshot: 'body-urlencoded-nested-object.openapi.yaml.failure.json', + }, +]; + +let updated = 0; +let unchanged = 0; + +function writeIfChanged(target, formatted) { + const prev = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''; + if (prev === formatted) { + unchanged += 1; + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, formatted); + updated += 1; + console.log(`updated: ${path.relative(repoRoot, target)}`); + } +} + +/** + * Write `result` as a path-only snapshot JSON plus a sibling directory + * holding each non-static artifact's contents at `/`. + * Static-template paths are listed in the JSON but their contents are + * pinned once under static-template/ (handled separately). + */ +function writeSuccessSnapshot(fixtureName, result) { + const jsonPath = path.join(snapshotDir, `${fixtureName}.success.json`); + const siblingsDir = path.join(snapshotDir, fixtureName); + + // Track which sibling files are still live; any leftover under + // siblingsDir gets removed at the end so renames/deletes don't leave + // orphans. + const live = new Set(); + for (const a of result.artifacts) { + if (STATIC_TEMPLATE_PATHS.has(a.path)) continue; + const stripped = a.contents.replace(BANNER_RE, ''); + const target = path.join(siblingsDir, a.path); + writeIfChanged(target, stripped); + live.add(path.relative(siblingsDir, target)); + } + removeOrphans(siblingsDir, live); + + const pathOnly = { + summary: result.summary, + diagnostics: result.diagnostics, + artifacts: result.artifacts.map(a => ({ path: a.path })), + }; + const formatted = JSON.stringify(pathOnly, null, 2) + '\n'; + writeIfChanged(jsonPath, formatted); +} + +function writeStaticTemplate(result) { + const live = new Set(); + for (const a of result.artifacts) { + if (!STATIC_TEMPLATE_PATHS.has(a.path)) continue; + const stripped = a.contents.replace(BANNER_RE, ''); + const target = path.join(STATIC_TEMPLATE_DIR, a.path); + writeIfChanged(target, stripped); + live.add(path.relative(STATIC_TEMPLATE_DIR, target)); + } + removeOrphans(STATIC_TEMPLATE_DIR, live); + + // Sort for deterministic ordering across regen runs. + const sortedPaths = [...STATIC_TEMPLATE_PATHS].sort(); + const index = { artifacts: sortedPaths.map(p => ({ path: p })) }; + const formatted = JSON.stringify(index, null, 2) + '\n'; + writeIfChanged(STATIC_TEMPLATE_INDEX, formatted); +} + +function removeOrphans(dir, live) { + if (!fs.existsSync(dir)) return; + const walk = (current, prefix) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const child = path.join(current, entry.name); + const rel = prefix ? path.join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) { + walk(child, rel); + // Remove empty intermediate directories left behind. + if (fs.readdirSync(child).length === 0) { + fs.rmdirSync(child); + } + } else if (!live.has(rel)) { + fs.rmSync(child); + console.log(`removed: ${path.relative(repoRoot, child)}`); + } + } + }; + walk(dir, ''); +} + +let staticTemplateWritten = false; +for (const fixture of successFixtures) { + const result = await generate({ + inputPath: path.join('test/fixtures', fixture), + ...DEFAULT_OPTIONS, + }); + if (!staticTemplateWritten) { + writeStaticTemplate(result); + staticTemplateWritten = true; + } + writeSuccessSnapshot(fixture, result); +} + +for (const { fixture, snapshot } of failureFixtures) { + let payload; + try { + await generate({ + inputPath: path.join('test/fixtures', fixture), + ...DEFAULT_OPTIONS, + }); + console.warn(`SKIP: ${fixture} succeeded — failure snapshot not regenerated`); + continue; + } catch (err) { + payload = failurePayload(err); + } + const next = JSON.stringify(payload, null, 2) + '\n'; + writeIfChanged(path.join(snapshotDir, snapshot), next); +} + +// Parameterised failure cases: same fixture, different option overrides +// each produce a distinct failure snapshot. +const parameterisedFailures = [ + { + fixture: 'petstore-rich.openapi.yaml', + options: { + mappedTypes: [{ schema: 'MissingSchema', import: '@demo/x', type: 'Missing' }], + }, + snapshot: 'petstore-rich.openapi.yaml.invalid-mapped-type.failure.json', + }, +]; + +for (const { fixture, options, snapshot } of parameterisedFailures) { + let payload; + try { + await generate({ + inputPath: path.join('test/fixtures', fixture), + ...DEFAULT_OPTIONS, + ...options, + }); + console.warn( + `SKIP: ${fixture} (${snapshot}) succeeded — failure snapshot not regenerated`, + ); + continue; + } catch (err) { + payload = failurePayload(err); + } + const next = JSON.stringify(payload, null, 2) + '\n'; + writeIfChanged(path.join(snapshotDir, snapshot), next); +} + +console.log(`\n${updated} snapshot(s) updated, ${unchanged} unchanged.`); diff --git a/src/bindings.rs b/src/bindings.rs new file mode 100644 index 0000000..12887af --- /dev/null +++ b/src/bindings.rs @@ -0,0 +1,336 @@ +use napi::bindgen_prelude::{Function, JsObjectValue, Object, Unknown}; +use napi::{Env, Error, Status}; +use napi_derive::napi; + +use crate::{ + error::{Diagnostic, DiagnosticCode, GeneratorDiagnostic}, + options::{GenerateConfig, MappedType, ResponseTypeMapping}, + pipeline::{GenerateFailure, GenerateResult as ApplicationGenerateResult}, + result::{GenerateSummary, GeneratedArtifact}, +}; + +/// Per-target emit selection. The `emit` option is the set of artifact +/// families to produce; each entry maps to one or more files. +#[napi(string_enum = "lowercase")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum EmitTarget { + Models, + Angular, +} + +/// User-facing naming config crossing the NAPI boundary. The JS wrapper +/// in `lib/index.js` unpacks each JS `RegExp` into the `{ source, flags +/// }` shape carried here, so Rust sees pure data on this side. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct NamingOptions { + pub method_name: Option, + pub group: Option, +} + +/// Discriminated union: a string shorthand, a single rule, or a chain +/// of rules-or-shorthands. NAPI cannot express true sum types, so we +/// use exclusive fields: exactly one of `string`, `rule`, or `chain` +/// must be set. The JS wrapper enforces this; the Rust validator +/// double-checks at config resolution. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct NamingValue { + /// `{ string: '...' }` — bare format-string shorthand. + pub string: Option, + /// `{ rule: { ... } }` — a single Rule. + pub rule: Option, + /// `{ chain: [...] }` — a sequence; each item is an exclusive + /// `{ string }` or `{ rule }`. + pub chain: Option>, +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct NamingChainItem { + pub string: Option, + pub rule: Option, +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct NamingRuleEntry { + pub from: Option, + pub parse: Option, + pub format: Option, + /// Lowercase per spec: 'camel' | 'pascal' | 'snake' | 'kebab' | 'constant'. + #[napi(js_name = "case")] + pub case_: Option, +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct NamingParseSpec { + pub source: String, + pub flags: String, +} + +#[napi(object)] +pub struct GenerateOptions { + /// Path to the spec on disk. Mutually exclusive with `input_contents`; + /// the option validator rejects requests that set both or neither. + pub input_path: Option, + /// Raw spec source. When set, `display_path` is required and the + /// 16 MiB byte cap applies to `input_contents.as_bytes().len()`. + /// JS wrapper fills this in for URL inputs. + pub input_contents: Option, + /// Banner / diagnostic display string. Required when `input_contents` + /// is set; ignored when `input_path` is set (the existing path + /// normalisation runs in that case). + pub display_path: Option, + /// Decoder hint. Only honoured when `input_contents` is set; combining + /// it with `input_path` is a shape error. + pub input_format: Option, + /// Optional. When undefined, generation runs in-memory (no files written). + /// Passing an empty string is rejected at option resolution. + pub output_path: Option, + pub emit: Vec, + pub mapped_types: Option>, + /// Per-content-type override of the generated response-decoding kind + /// (`json | blob | text | arrayBuffer`). Read by the normalize stage + /// when picking how a successful response body is decoded. + pub response_type_mapping: Option>, + pub naming: Option, +} + +/// Explicit decoder selection. Skips both extension-based detection and +/// the JSON-then-YAML sniff fallback. Honoured only with `input_contents`. +#[napi(string_enum = "lowercase")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputFormat { + Json, + Yaml, +} + +#[napi(object)] +pub struct GenerateResult { + pub summary: GenerateSummary, + pub diagnostics: Vec, + pub artifacts: Vec, +} + +/// Payload attached to every fatal native throw. The JS wrapper in +/// `lib/index.js` upgrades the thrown plain Error into a `GenerateError` +/// (a real JS class that extends Error), copying these own-properties +/// across so consumers can `instanceof GenerateError` and still read +/// `code/subcode/message/path/warnings`. +/// +/// The fatal sits at the top level (`code/subcode/message/path`); pre-fatal +/// warnings ride in `warnings`. `subcode` is set for `PolicyViolation` +/// codes; it is `null` for every other category. +#[napi(object)] +pub struct GenerateErrorPayload { + pub code: String, + pub subcode: Option, + pub message: String, + pub path: String, + pub warnings: Vec, +} + +/// Sentinel set on every thrown error so the JS wrapper can identify +/// them without leaking the marker into application code (consumers +/// guard with `err instanceof GenerateError`, not by inspecting this). +/// +/// The value is read from `lib/error-marker.json` at compile time by +/// `build.rs`, the same file `lib/index.js` reads at module load. Single +/// source of truth — the two sides cannot drift. +const GENERATE_ERROR_MARKER: &str = env!("OPENAPI_NG_ERROR_MARKER"); + +/// Project a `catch_unwind` payload into the same `GenerateError` shape +/// that fatal diagnostics produce. The two common payload types are +/// `&'static str` (from `panic!("literal")`) and `String` (from +/// `panic!("{}", ...)` / `panic!(format!(...))`); everything else +/// collapses to a generic fallback message so the surface stays bounded. +/// +/// The result is a `napi::Error` indistinguishable from the one a typed +/// fatal would produce, so the JS wrapper upgrades it to a real +/// `GenerateError` via the same path and consumers can write +/// `err.code === 'E_UNEXPECTED'`. +pub(crate) fn map_panic(panic: Box, env: Env) -> Error { + let message = panic + .downcast_ref::<&'static str>() + .map(|s| (*s).to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "openapi-ng: unexpected panic in native binding".to_string()); + let fatal = Diagnostic { + code: DiagnosticCode::Unexpected, + subcode: None, + message: format!("unexpected panic in native binding: {message}"), + path: std::rc::Rc::from(""), + }; + map_failure( + GenerateFailure { + warnings: Vec::new(), + fatal, + }, + env, + ) +} + +pub(crate) fn map_failure(failure: GenerateFailure, env: Env) -> Error { + let GenerateFailure { warnings, fatal } = failure; + let fatal = fatal.to_napi_error(); + let warnings: Vec = + warnings.iter().map(Diagnostic::to_napi_warning).collect(); + try_enrich_error(&fatal, &warnings, env).unwrap_or_else(|_| { + // Boundary-side decoration failed (typically OOM during JS Object + // construction): embed the diagnostic code and warning count into the + // message so consumers branching on `err.code` still get a usable + // signal instead of a bare Error. + let dropped_suffix = if warnings.is_empty() { + String::new() + } else { + format!(" ({} warning(s) dropped)", warnings.len()) + }; + Error::new( + Status::GenericFailure, + format!("[{}] {}{}", fatal.code, fatal.message, dropped_suffix), + ) + }) +} + +fn try_enrich_error( + fatal: &GeneratorDiagnostic, + warnings: &[GeneratorDiagnostic], + env: Env, +) -> napi::Result { + // Build a plain JS Error and decorate it with the public own-properties. + // The JS wrapper in `lib/index.js` then re-throws as a GenerateError + // class instance so `err instanceof GenerateError` works while keeping + // the native binding free of subclass-of-Error gymnastics that + // `napi_is_error` doesn't honor for `#[napi]` classes. + let global = env.get_global()?; + let error_ctor: Function = global.get_named_property("Error")?; + let unknown = error_ctor.new_instance(fatal.message.clone())?; + // SAFETY: `unknown` was just constructed on the previous line via + // `error_ctor.new_instance(...)` against the global `Error` constructor, + // which always returns a JS `Object`. Downcasting back to `Object` therefore + // cannot violate the napi-rs type invariant on `Unknown::cast`. + let mut js_error: Object = unsafe { unknown.cast()? }; + js_error.set_named_property("code", fatal.code.clone())?; + if let Some(subcode) = fatal.subcode.clone() { + js_error.set_named_property("subcode", subcode)?; + } + js_error.set_named_property("path", fatal.path.clone())?; + js_error.set_named_property("warnings", warnings.to_vec())?; + js_error.set_named_property(GENERATE_ERROR_MARKER, true)?; + Ok(Error::from(unknown)) +} + +/// Boundary projection: take the wire-shaped `GenerateOptions` from the +/// JS caller and lower it into the resolved `GenerateConfig` the domain +/// pipeline consumes. Lives in `bindings.rs` (not `options.rs`) so the +/// domain doesn't depend on the NAPI boundary types. +impl From for GenerateConfig { + fn from(value: GenerateOptions) -> Self { + Self { + input_path: value.input_path, + input_contents: value.input_contents, + display_path: value.display_path, + input_format: value.input_format, + output_path: value.output_path, + emit: value.emit.into_iter().collect(), + mapped_types: value.mapped_types.unwrap_or_default(), + response_type_mapping: value.response_type_mapping.unwrap_or_default(), + naming_options: value.naming, + naming: crate::plan::naming::NamingConfig::default(), + } + } +} + +pub(crate) fn map_generate_result(value: ApplicationGenerateResult) -> GenerateResult { + GenerateResult { + summary: value.summary, + // Pipeline-collected diagnostics are warnings — fatals exit via the + // `Err(GenerateFailure)` arm and are projected in `map_failure`. + diagnostics: value + .diagnostics + .iter() + .map(Diagnostic::to_napi_warning) + .collect(), + artifacts: value.artifacts, + } +} + +#[cfg(test)] +mod tests { + use crate::{ + bindings::{EmitTarget, GenerateOptions}, + error::{Diagnostic, DiagnosticCode}, + options::GenerateConfig, + pipeline::GenerateResult as ApplicationGenerateResult, + result::{GenerateSummary, GeneratedArtifact}, + }; + + #[test] + fn from_collects_emit_targets_into_the_resolved_set() { + let config = GenerateConfig::from(GenerateOptions { + input_path: Some("spec.yaml".to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: Some("out".to_string()), + emit: vec![EmitTarget::Models, EmitTarget::Angular], + mapped_types: None, + response_type_mapping: None, + naming: None, + }); + + assert!(config.emit.contains(&EmitTarget::Models)); + assert!(config.emit.contains(&EmitTarget::Angular)); + } + + #[test] + fn from_deduplicates_repeated_emit_targets() { + let config = GenerateConfig::from(GenerateOptions { + input_path: Some("spec.yaml".to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: Some("out".to_string()), + emit: vec![EmitTarget::Models, EmitTarget::Models, EmitTarget::Angular], + mapped_types: None, + response_type_mapping: None, + naming: None, + }); + + assert_eq!(config.emit.len(), 2); + assert!(config.emit.contains(&EmitTarget::Models)); + assert!(config.emit.contains(&EmitTarget::Angular)); + } + + #[test] + fn map_generate_result_projects_domain_artifacts_to_napi_shape() { + let result = super::map_generate_result(ApplicationGenerateResult { + summary: GenerateSummary { + normalized_source_path: "test/fixtures/petstore-minimal.openapi.yaml".to_string(), + spec_version: "3.0.3".to_string(), + title: "Petstore Minimal".to_string(), + path_count: 1, + operation_count: 1, + schema_count: 1, + }, + diagnostics: vec![Diagnostic::new( + DiagnosticCode::UnsupportedSemantic, + "Example warning", + std::rc::Rc::from("spec.yaml"), + )], + artifacts: vec![GeneratedArtifact::new( + "model.generated.ts".to_string(), + "export interface Pet {}\n".to_string(), + )], + }); + + assert_eq!(result.summary.title, "Petstore Minimal"); + assert_eq!(result.artifacts.len(), 1); + assert_eq!(result.artifacts[0].path, "model.generated.ts"); + assert_eq!(result.artifacts[0].contents, "export interface Pet {}\n"); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].code, "E_UNSUPPORTED_SEMANTIC"); + } +} diff --git a/src/emit/angular/imports.rs b/src/emit/angular/imports.rs new file mode 100644 index 0000000..951dad4 --- /dev/null +++ b/src/emit/angular/imports.rs @@ -0,0 +1,262 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::emit::typescript::{self as ts, Writer}; +use crate::ir::canonical::ResponseContent; +use crate::ir::schema::collect_type_references; +use crate::plan::artifact_plan::{PlannedOperation, PlannedRequestBody, RequestFieldKind}; + +/// Relative path from a generated service file (`rest/*.rest.generated.ts`) +/// to the sibling `model.generated.ts` that holds all emitted TypeScript +/// types. Fixed by the emit layout — services always live one directory +/// below the model artifact — so it is a constant rather than a plan field. +const MODEL_IMPORT_PATH: &str = "../model.generated"; + +pub(super) fn render_service_imports( + buffer: &mut Writer, + operations: &[PlannedOperation<'_>], + helper_import_path: &str, +) { + buffer.line("import { Injectable } from '@angular/core';"); + + let uses_http_params = operations.iter().any(|operation| { + operation + .request + .fields + .iter() + .any(|f| f.kind == RequestFieldKind::Query) + }); + let helper_import = if uses_http_params { + format!("import {{ httpParams, requestFactory }} from '{helper_import_path}';") + } else { + format!("import {{ requestFactory }} from '{helper_import_path}';") + }; + buffer.line(&helper_import); + + let mut imports: BTreeSet<&str> = BTreeSet::new(); + for operation in operations { + for field in &operation.request.fields { + collect_type_references(field.ty, &mut imports); + } + for header in &operation.request.headers { + collect_type_references(header.ty, &mut imports); + } + // Body types contribute imports according to the body's layout. A + // `Nested` body's ty (named ref or any other `SchemaType`) imports + // straight from the type printer. A `FlatJson` body hoists each + // property's `SchemaType` to a top-level field, so each property + // contributes the same way path/query/header types do. Form bodies + // type their fields via `BodyFieldType`, which never references + // user-declared schemas — they add nothing. + match &operation.request.body { + Some(PlannedRequestBody::Nested { ty, .. }) => { + collect_type_references(ty, &mut imports); + } + Some(PlannedRequestBody::FlatJson { properties, .. }) => { + for prop in properties { + collect_type_references(prop.ty, &mut imports); + } + } + Some(PlannedRequestBody::Multipart { .. } | PlannedRequestBody::UrlEncoded { .. }) | None => { + } + } + if let Some(response) = &operation.response { + match response { + ResponseContent::Json(Some(ty)) => { + collect_type_references(ty, &mut imports); + } + // `Json(None)` and non-JSON variants render to fixed TS surfaces + // (`void` / `Blob` / `string` / `ArrayBuffer`) that never reference + // user-declared schemas, so they contribute nothing to the import + // set. Non-JSON variants are not yet produced by normalize but the + // match is exhaustive so a future addition forces a compile error. + ResponseContent::Json(None) + | ResponseContent::Blob + | ResponseContent::Text + | ResponseContent::ArrayBuffer => {} + } + } + // Error-response body types contribute imports the same way as the + // success response: they appear by name in the per-operation + // `{Pascal}Error` interface emitted alongside `{Pascal}Params`. + for error in operation.errors { + collect_type_references(&error.body, &mut imports); + } + } + + if !imports.is_empty() { + let by_path = BTreeMap::from([(MODEL_IMPORT_PATH, imports)]); + ts::import_block(buffer, &by_path, true); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::canonical::HttpMethod; + use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::plan::artifact_plan::{ + PlannedHeader, PlannedRequestContract, PlannedRequestField, RequestFieldKind, + }; + use crate::test_support::{body_field, empty_request, flat_json_body, nested_body, op_with}; + + fn render(operations: &[PlannedOperation<'_>]) -> String { + let mut buf = Writer::with_capacity(1024); + render_service_imports(&mut buf, operations, "../rest.util"); + buf.into_string() + } + + // ── Fixed-position imports (HttpClient, Angular core, helpers) ───────────── + + #[test] + fn always_imports_injectable() { + let out = render(&[op_with( + "ping", + HttpMethod::Get, + "/x", + empty_request(), + None, + )]); + assert!(out.contains("import { Injectable } from '@angular/core';")); + assert!(!out.contains("HttpClient")); + } + + #[test] + fn helper_import_omits_http_params_when_no_query_fields_exist() { + let out = render(&[op_with( + "ping", + HttpMethod::Get, + "/x", + empty_request(), + None, + )]); + assert!(out.contains("import { requestFactory } from '../rest.util';")); + assert!(!out.contains("httpParams")); + } + + #[test] + fn helper_import_includes_http_params_when_any_operation_has_query_fields() { + let limit_ty = SchemaType::Scalar(SchemaScalar::Number); + let request = PlannedRequestContract { + fields: vec![PlannedRequestField { + name: "limit".into(), + optional: true, + ty: &limit_ty, + kind: RequestFieldKind::Query, + }], + headers: vec![], + body: None, + }; + let out = render(&[op_with("listPets", HttpMethod::Get, "/x", request, None)]); + assert!(out.contains("import { httpParams, requestFactory } from '../rest.util';")); + } + + // ── Model-ref import dedup ──────────────────────────────────────────────── + + #[test] + fn model_refs_are_deduplicated_across_operations() { + let pet_ref = SchemaType::Ref("Pet".into()); + let pet_response = ResponseContent::Json(Some(SchemaType::Ref("Pet".into()))); + let op_a = op_with( + "getPet", + HttpMethod::Get, + "/x", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(nested_body(&pet_ref, false)), + }, + Some(&pet_response), + ); + let op_b = op_with( + "listPets", + HttpMethod::Get, + "/x", + empty_request(), + Some(&pet_response), + ); + let out = render(&[op_a, op_b]); + // The single import line lists `Pet` exactly once. + assert!(out.contains("import type { Pet } from '../model.generated';")); + assert_eq!(out.matches("Pet").count(), 1); + } + + #[test] + fn model_refs_from_headers_are_imported() { + let key_ty = SchemaType::Ref("IdempotencyKey".into()); + let request = PlannedRequestContract { + fields: vec![], + headers: vec![PlannedHeader { + name: "X-Idempotency-Key".into(), + optional: false, + ty: &key_ty, + }], + body: None, + }; + let out = render(&[op_with("createPet", HttpMethod::Get, "/x", request, None)]); + assert!(out.contains("import type { IdempotencyKey } from '../model.generated';")); + } + + // ── Body imports under smart-flatten ────────────────────────────────────── + + #[test] + fn nested_body_named_ref_is_imported() { + let payload_ref = SchemaType::Ref("CreatePetPayload".into()); + let request = PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(nested_body(&payload_ref, false)), + }; + let out = render(&[op_with("createPet", HttpMethod::Post, "/x", request, None)]); + assert!(out.contains("import type { CreatePetPayload } from '../model.generated';")); + } + + #[test] + fn flat_json_body_property_refs_are_imported() { + // Smart-flatten hoists inline-object body properties to top-level; each + // property's `SchemaType` contributes imports the same way path/query + // types do. + let status_ref = SchemaType::Ref("PetStatus".into()); + let request = PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(flat_json_body( + vec![body_field("status", false, &status_ref)], + true, + )), + }; + let out = render(&[op_with("createPet", HttpMethod::Post, "/x", request, None)]); + assert!(out.contains("import type { PetStatus } from '../model.generated';")); + } + + #[test] + fn nested_body_and_response_sharing_a_ref_yields_a_single_dedupe_import() { + let pet_ref = SchemaType::Ref("Pet".into()); + let pet_response = ResponseContent::Json(Some(SchemaType::Ref("Pet".into()))); + let request = PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(nested_body(&pet_ref, false)), + }; + let out = render(&[op_with( + "createPet", + HttpMethod::Post, + "/x", + request, + Some(&pet_response), + )]); + assert!(out.contains("import type { Pet } from '../model.generated';")); + assert_eq!(out.matches("Pet").count(), 1); + } + + // ── empty operation set ─────────────────────────────────────────────────── + + #[test] + fn empty_operation_set_emits_only_fixed_imports() { + let out = render(&[]); + assert!(out.contains("Injectable")); + assert!(out.contains("requestFactory")); + assert!(!out.contains("HttpClient")); + assert!(!out.contains("httpParams")); + assert!(!out.contains("../model.generated")); + } +} diff --git a/src/emit/angular/mod.rs b/src/emit/angular/mod.rs new file mode 100644 index 0000000..ad98652 --- /dev/null +++ b/src/emit/angular/mod.rs @@ -0,0 +1,91 @@ +mod imports; +mod request; +mod service; + +pub(crate) use service::emit_service; + +pub(crate) const REST_MODEL_PATH: &str = "rest.model.ts"; +pub(crate) const REST_UTIL_PATH: &str = "rest.util.ts"; +pub(crate) const REST_MODEL_TEMPLATE: &str = + include_str!("../../../templates/angular/rest.model.ts"); +pub(crate) const REST_UTIL_TEMPLATE: &str = include_str!("../../../templates/angular/rest.util.ts"); + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::canonical::HttpMethod; + use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::plan::artifact_plan::{ + PlannedOperation, PlannedRequestContract, PlannedRequestField, RequestFieldKind, ServicePlan, + }; + use crate::test_support::empty_request; + + #[test] + fn rest_model_template_carries_common_request_definitions() { + assert!(REST_MODEL_TEMPLATE.contains("CommonRequest")); + } + + #[test] + fn rest_util_template_carries_request_factory_helpers() { + assert!(REST_UTIL_TEMPLATE.contains("requestFactory")); + } + + #[test] + fn emit_service_generates_injectable_class_with_operation_property() { + let plan = ServicePlan { + group_name: "pet".into(), + class_name: "PetRest".into(), + artifact_path: "rest/pet.rest.generated.ts".to_string(), + operations: vec![PlannedOperation { + operation_id: "listPets".to_string(), + method_name: "listPets".to_string(), + method: HttpMethod::Get, + path: "/pets".to_string(), + request: empty_request(), + response: None, + errors: &[], + description: None, + deprecated: false, + }], + }; + let content = emit_service(&plan); + assert!(content.contains("@Injectable(")); + assert!(content.contains("export class PetRest")); + assert!(content.contains("requestFactory")); + assert!(content.contains("listPets")); + } + + #[test] + fn emit_service_includes_request_interface_when_operation_has_input_fields() { + let ty = SchemaType::Scalar(SchemaScalar::String); + let plan = ServicePlan { + group_name: "pet".into(), + class_name: "PetRest".into(), + artifact_path: "rest/pet.rest.generated.ts".to_string(), + operations: vec![PlannedOperation { + operation_id: "updatePet".to_string(), + method_name: "updatePet".to_string(), + method: HttpMethod::Put, + path: "/pets/{id}".to_string(), + request: PlannedRequestContract { + fields: vec![PlannedRequestField { + name: "id".into(), + optional: false, + ty: &ty, + kind: RequestFieldKind::Path, + }], + headers: vec![], + body: None, + }, + response: None, + errors: &[], + description: None, + deprecated: false, + }], + }; + let content = emit_service(&plan); + assert!(content.contains("export interface UpdatePetParams")); + assert!(content.contains("UpdatePetParams")); + assert!(content.contains("id:")); + } +} diff --git a/src/emit/angular/request.rs b/src/emit/angular/request.rs new file mode 100644 index 0000000..346e1ec --- /dev/null +++ b/src/emit/angular/request.rs @@ -0,0 +1,869 @@ +use crate::emit::typescript::{self as ts, Position, Writer, render_type, safe_property_name}; +use crate::ir::canonical::BodyFieldType; +use crate::ir::schema::{SchemaProperty, SchemaType}; +use crate::plan::artifact_plan::{PlannedOperation, PlannedRequestBody, RequestFieldKind}; +use crate::wln; + +/// Which form-body flavor the inline IIFE builds. +/// +/// Emit-local — distinct from the normalize-stage `FormKind` because the +/// concerns differ: normalize uses it to dispatch the body walker, while +/// emit uses it to pick the runtime constructor (`FormData` vs +/// `URLSearchParams`) and the TS return type. Sharing the enum across +/// stages would couple emit to normalize for no real reuse. +#[derive(Clone, Copy)] +enum FormKind { + Multipart, + UrlEncoded, +} + +pub(super) fn render_requestful_builder( + buffer: &mut Writer, + operation: &PlannedOperation<'_>, + interface_name: &str, +) { + buffer.open_block(&format!("(request: {interface_name}) =>")); + + let mut destructured: Vec<&str> = operation + .request + .fields + .iter() + .map(|f| f.name.as_ref()) + .collect(); + // Body destructure depends on the body's layout: `Nested` introduces a + // single `body` identifier, while flat-JSON/form bodies destructure + // each field by name so the builder can reference them as bare + // identifiers in the assembled `body:` expression (object literal / + // `fd.append('name', name)`). + match &operation.request.body { + None => {} + Some(PlannedRequestBody::Nested { .. }) => destructured.push("body"), + Some(PlannedRequestBody::FlatJson { properties, .. }) => { + destructured.extend(properties.iter().map(|p| p.name.as_ref())); + } + Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { + destructured.extend(fields.iter().map(|f| f.name.as_ref())); + } + } + if !operation.request.headers.is_empty() { + destructured.push("headers"); + } + if !destructured.is_empty() { + wln!(buffer, "const {{ {} }} = request;", destructured.join(", ")); + } + + buffer.open_block("return"); + wln!(buffer, "method: '{}',", operation.method); + write_path_template_line(buffer, &operation.path); + if let Some(params_expression) = render_params_expression(operation) { + wln!(buffer, "params: {params_expression},"); + } + if let Some(body_expression) = render_body_expression(operation) { + wln!(buffer, "body: {body_expression},"); + } + if !operation.request.headers.is_empty() { + buffer.line("headers,"); + } + buffer.close_block(";"); + + buffer.close_block(","); +} + +pub(super) fn render_zero_arg_builder(buffer: &mut Writer, operation: &PlannedOperation<'_>) { + buffer.line("() => ({"); + buffer.indent(); + wln!(buffer, "method: '{}',", operation.method); + write_path_template_line(buffer, &operation.path); + buffer.dedent(); + buffer.line("}),"); +} + +/// Stream `url: \`\`,\n` into `buffer`, expanding each +/// `{name}` placeholder to `${encodeURIComponent(name)}` without +/// allocating a separate `String` per template. +fn write_path_template_line(buffer: &mut Writer, path: &str) { + buffer.push("url: `"); + write_path_template_into(buffer, path); + buffer.push("`,\n"); +} + +pub(super) fn render_request_interface( + buffer: &mut Writer, + operation: &PlannedOperation<'_>, + request_name: &str, +) { + // Manual emit (instead of `ts::interface_block`) because the body's + // hoisted fields can mix `SchemaType` (flat-JSON body properties) with + // `BodyFieldType` (form-body fields). The two share no enum — form-field + // types are deliberately constrained (`Scalar | ArrayOfScalar | Binary + // | ArrayOfBinary`) — so we render each group with its own type printer + // and keep the ordering invariant: path → query → body → headers. + buffer.open_block(&format!("export interface {request_name}")); + + // Path / query parameters at the top. + for field in &operation.request.fields { + ts::write_property_declaration(buffer, field.name.as_ref(), field.optional, field.ty); + buffer.push(";\n"); + } + + // Body. Smart-flatten dispatches on the body kind: + // - Nested → single `body: T` field (preserves named-ref identity). + // - FlatJson → hoist each property as a top-level field (matches the + // spec's authorial intent for unnamed object bodies). + // - Multipart / UrlEncoded → hoist each form-field as a top-level + // entry rendered through the BodyFieldType printer. + match &operation.request.body { + None => {} + Some(PlannedRequestBody::Nested { ty, optional }) => { + ts::write_property_declaration(buffer, "body", *optional, ty); + buffer.push(";\n"); + } + Some(PlannedRequestBody::FlatJson { properties, .. }) => { + for prop in properties { + ts::write_property_declaration(buffer, prop.name.as_ref(), prop.optional, prop.ty); + buffer.push(";\n"); + } + } + Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { + for form in fields { + let name = safe_property_name(form.name.as_ref()).into_owned(); + let optional_marker = if form.optional { "?" } else { "" }; + let ts_type = ts::render_body_field_type(form.ty); + wln!(buffer, "{name}{optional_marker}: {ts_type};"); + } + } + } + + // Synthetic `headers` block. Materialized here at the writer level + // (not at plan time) so the plan's `headers` list stays a simple + // sibling of `fields`. Headers carry no per-field deprecation — + // OpenAPI's Parameter Object has `deprecated` on Operation/Schema + // but not on header parameters — so each property's trailing flag + // is `false`. + if !operation.request.headers.is_empty() { + let header_props: Vec = operation + .request + .headers + .iter() + .map(|h| SchemaProperty { + name: h.name.clone(), + required: !h.optional, + ty: h.ty.clone(), + description: None, + deprecated: false, + }) + .collect(); + let all_optional = operation.request.headers.iter().all(|h| h.optional); + let headers_ty = SchemaType::InlineObject { + properties: header_props, + }; + ts::write_property_declaration(buffer, "headers", all_optional, &headers_ty); + buffer.push(";\n"); + } + + buffer.close_block(""); +} + +/// Renders the per-operation `{Pascal}Error` interface — a numeric-status-keyed +/// map of error body types, e.g. +/// +/// ```ignore +/// export interface UpdatePetError { +/// 400: ValidationProblem; +/// 500: { traceId: string }; +/// } +/// ``` +/// +/// Lives in the service file (alongside `{Pascal}Params`) so the per-operation +/// typed surfaces are colocated. Consumers access individual body types via +/// `UpdatePetError[400]` and cast `HttpErrorResponse.error` themselves — the +/// framework types `.error` as `any`, so this is a documentation/help type, +/// not a runtime guarantee. +pub(super) fn render_error_interface( + buffer: &mut Writer, + operation: &PlannedOperation<'_>, + error_name: &str, +) { + buffer.open_block(&format!("export interface {error_name}")); + for error in operation.errors { + buffer.push(&error.status.to_string()); + buffer.push(": "); + render_type(buffer, &error.body, Position::Standalone); + buffer.push(";\n"); + } + buffer.close_block(""); +} + +fn render_params_expression(operation: &PlannedOperation<'_>) -> Option { + let query_fields: Vec<&str> = operation + .request + .fields + .iter() + .filter(|f| f.kind == RequestFieldKind::Query) + .map(|f| f.name.as_ref()) + .collect(); + if query_fields.is_empty() { + return None; + } + + // When all query fields are optional and undefined at call time, the + // emitted `httpParams({...})` produces an empty `HttpParams` (the helper + // in templates/angular/rest.util.ts skips undefined values). We keep the + // unconditional emit instead of a per-call runtime guard because the + // empty-params path is a cheap no-op and the alternative spread guard + // (`...(a !== undefined ? { params: ... } : {})`) is noisier than the + // cost it saves. + Some(format!("httpParams({{ {} }})", query_fields.join(", "),)) +} + +fn render_body_expression(operation: &PlannedOperation<'_>) -> Option { + match operation.request.body.as_ref()? { + // Nested bodies forward verbatim via property shorthand — `body,` in + // the builder return literal. + PlannedRequestBody::Nested { .. } => Some("body".to_string()), + // Flat-JSON bodies re-assemble the hoisted properties into an object + // literal by destructured name, restoring the original body shape on + // the wire. + PlannedRequestBody::FlatJson { properties, .. } => { + let names: Vec<&str> = properties.iter().map(|p| p.name.as_ref()).collect(); + Some(format!("{{ {} }}", names.join(", "))) + } + PlannedRequestBody::Multipart { fields } => Some(render_form_body(fields, FormKind::Multipart)), + PlannedRequestBody::UrlEncoded { fields } => { + Some(render_form_body(fields, FormKind::UrlEncoded)) + } + } +} + +/// Build the inline IIFE that materializes a form-body request payload. +/// +/// Returns a multi-line `String` whose first line starts with `((): ... => {` +/// and whose final line ends with `})()` — to be interpolated as the value +/// of a `body:` property at indent level 2 inside `render_requestful_builder`. +/// The `Writer` re-indents each `\n`-terminated line with its current cache, +/// so the leading spaces on continuation lines stack on top of that prefix. +/// +/// The outer builder destructures each form field from `request` directly +/// (smart-flatten hoists form fields to top-level), so the IIFE references +/// each by bare identifier in the append calls. +/// +/// Per-field append rules are keyed on `BodyFieldType`: +/// - `Scalar` and `ArrayOfScalar` wrap the value in `String(...)` because +/// `FormData.append` / `URLSearchParams.append` accept only string or Blob. +/// - `Binary` and `ArrayOfBinary` skip the cast — `File`/`Blob` are valid +/// `FormData` entries as-is; `URLSearchParams` doesn't support binary so +/// normalize rejects those fields upstream. +/// - Optional fields wrap in `if (name !== undefined) ...` to preserve the +/// "no key present" semantics; required fields emit unguarded. +fn render_form_body( + fields: &[crate::plan::artifact_plan::PlannedFormField<'_>], + kind: FormKind, +) -> String { + let (ctor, var, ts_type) = match kind { + FormKind::Multipart => ("new FormData()", "fd", "FormData"), + FormKind::UrlEncoded => ("new URLSearchParams()", "params", "URLSearchParams"), + }; + let mut out = String::new(); + out.push_str(&format!("((): {ts_type} => {{\n")); + out.push_str(&format!(" const {var} = {ctor};\n")); + for f in fields { + let name = f.name.as_ref(); + let guard_open = if f.optional { + format!("if ({name} !== undefined) ") + } else { + String::new() + }; + let append_call = match f.ty { + BodyFieldType::Scalar(_) => format!("{var}.append('{name}', String({name}));"), + BodyFieldType::ArrayOfScalar(_) => { + format!("for (const v of {name}) {var}.append('{name}', String(v));") + } + BodyFieldType::Binary => format!("{var}.append('{name}', {name});"), + BodyFieldType::ArrayOfBinary => { + format!("for (const v of {name}) {var}.append('{name}', v);") + } + }; + out.push_str(" "); + out.push_str(&guard_open); + out.push_str(&append_call); + out.push('\n'); + } + out.push_str(&format!(" return {var};\n")); + out.push_str("})()"); + out +} + +/// Stream `path` into `buffer`, expanding each `{name}` placeholder to +/// `${encodeURIComponent(name)}`. Operates on string slices so the +/// per-placeholder name never lands in its own heap allocation; the +/// caller's buffer absorbs every byte directly. +/// +/// Balanced braces are a normalize-stage invariant +/// (`validate_path_template` rejects unmatched `{` / `}` before this +/// runs), so the loop never encounters a stray brace. The unbalanced-`{` +/// branch survives as a defensive fallback that emits the remainder +/// verbatim rather than panicking — preferable to surfacing a +/// generator panic on adversarial IR. +fn write_path_template_into(buffer: &mut Writer, path: &str) { + let mut rest = path; + while let Some(open) = rest.find('{') { + buffer.push(&rest[..open]); + let after_open = &rest[open + 1..]; + let Some(close) = after_open.find('}') else { + debug_assert!( + false, + "path template `{path}` reached emit with an unmatched '{{'; normalize must reject it" + ); + buffer.push(after_open); + return; + }; + buffer.push("${encodeURIComponent("); + buffer.push(&after_open[..close]); + buffer.push(")}"); + rest = &after_open[close + 1..]; + } + buffer.push(rest); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::canonical::{BodyFieldType, ErrorResponse, HttpMethod}; + use crate::ir::schema::{SchemaProperty, SchemaScalar}; + use crate::plan::artifact_plan::{PlannedHeader, PlannedRequestContract}; + use crate::test_support::{ + body_field, flat_json_body, nested_body, op_with, op_with_errors, op_with_multipart_fields, + op_with_multipart_fields_full, op_with_urlencoded_fields, path_field, query_field, string_ty, + }; + + // ── render_error_interface ──────────────────────────────────────────────── + + fn render_errors(error_name: &str, errors: &[ErrorResponse]) -> String { + let op = op_with_errors("op", errors); + let mut buf = Writer::with_capacity(256); + render_error_interface(&mut buf, &op, error_name); + buf.into_string() + } + + #[test] + fn error_interface_emits_numeric_status_keys_in_source_order() { + let errors = vec![ + ErrorResponse { + status: 400, + body: SchemaType::Ref("ValidationProblem".into()), + }, + ErrorResponse { + status: 500, + body: SchemaType::Ref("ServerError".into()), + }, + ]; + let out = render_errors("UpdatePetError", &errors); + assert!(out.contains("export interface UpdatePetError {")); + let four = out.find("400: ValidationProblem;").expect("400 entry"); + let five = out.find("500: ServerError;").expect("500 entry"); + assert!(four < five, "entries must follow input order, got:\n{out}"); + } + + #[test] + fn error_interface_emits_inline_object_bodies_verbatim() { + let body = SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "code".into(), + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + description: None, + deprecated: false, + }], + }; + let errors = vec![ErrorResponse { status: 422, body }]; + let out = render_errors("CreatePetError", &errors); + assert!(out.contains("422: {")); + assert!(out.contains("code: string;")); + } + + // ── render_requestful_builder ────────────────────────────────────────────── + + #[test] + fn requestful_builder_renders_get_with_path_param_only() { + let ty = string_ty(); + let op = op_with( + "getPet", + HttpMethod::Get, + "/pets/{petId}", + PlannedRequestContract { + fields: vec![path_field("petId", &ty)], + headers: vec![], + body: None, + }, + None, + ); + + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "GetPetParams"); + let out = buf.into_string(); + + assert!(out.contains("(request: GetPetParams) =>")); + assert!(out.contains("const { petId } = request;")); + assert!(out.contains("method: 'GET',")); + assert!(out.contains("url: `/pets/${encodeURIComponent(petId)}`,")); + // GET with path-only: no params/body/headers lines. + assert!(!out.contains("params:")); + assert!(!out.contains("body:")); + assert!(!out.contains("headers,")); + } + + #[test] + fn requestful_builder_renders_post_with_ref_body_and_headers() { + let str_ty = string_ty(); + let body_ref = SchemaType::Ref("CreatePetPayload".into()); + let op = op_with( + "createPet", + HttpMethod::Post, + "/pets", + PlannedRequestContract { + fields: vec![], + headers: vec![PlannedHeader { + name: "X-Trace-Id".into(), + optional: false, + ty: &str_ty, + }], + body: Some(nested_body(&body_ref, false)), + }, + None, + ); + + let mut buf = Writer::with_capacity(1024); + render_requestful_builder(&mut buf, &op, "CreatePetParams"); + let out = buf.into_string(); + + assert!(out.contains("(request: CreatePetParams) =>")); + assert!(out.contains("const { body, headers } = request;")); + assert!(out.contains("method: 'POST',")); + assert!(out.contains("url: `/pets`,")); + // Nested body forwards verbatim via shorthand. + assert!(out.contains("body: body,")); + assert!(out.contains("headers,")); + } + + #[test] + fn requestful_builder_assembles_object_literal_for_flat_json_body() { + // Smart-flatten: inline JSON object bodies hoist properties to + // top-level fields. The builder re-assembles them into an object + // literal at the `body:` slot. + let str_ty = string_ty(); + let bool_ty = SchemaType::Scalar(SchemaScalar::Boolean); + let op = op_with( + "decide", + HttpMethod::Post, + "/decide", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(flat_json_body( + vec![ + body_field("csvImportId", false, &str_ty), + body_field("doImport", false, &bool_ty), + ], + true, + )), + }, + None, + ); + + let mut buf = Writer::with_capacity(1024); + render_requestful_builder(&mut buf, &op, "DecideParams"); + let out = buf.into_string(); + + assert!(out.contains("const { csvImportId, doImport } = request;")); + assert!(out.contains("body: { csvImportId, doImport },")); + } + + #[test] + fn requestful_builder_renders_query_params_via_http_params() { + let str_ty = string_ty(); + let op = op_with( + "listPets", + HttpMethod::Get, + "/pets", + PlannedRequestContract { + fields: vec![ + query_field("limit", true, &str_ty), + query_field("offset", true, &str_ty), + ], + headers: vec![], + body: None, + }, + None, + ); + + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "ListPetsParams"); + let out = buf.into_string(); + + assert!(out.contains("const { limit, offset } = request;")); + assert!(out.contains("params: httpParams({ limit, offset }),")); + assert!(!out.contains("body:")); + } + + #[test] + fn requestful_builder_renders_non_object_json_body_as_nested_shorthand() { + let payload_ty = SchemaType::Scalar(SchemaScalar::String); + let op = op_with( + "uploadPayload", + HttpMethod::Post, + "/upload", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(nested_body(&payload_ty, false)), + }, + None, + ); + + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "UploadPayloadParams"); + let out = buf.into_string(); + + // Non-object JSON bodies have no property structure to hoist, so they + // stay nested under `body` and forward via property shorthand. + assert!(out.contains("const { body } = request;")); + assert!(out.contains("body: body,")); + } + + // ── render_zero_arg_builder ──────────────────────────────────────────────── + + #[test] + fn zero_arg_builder_renders_no_request_destructure() { + let op = op_with( + "ping", + HttpMethod::Get, + "/ping", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: None, + }, + None, + ); + + let mut buf = Writer::with_capacity(256); + render_zero_arg_builder(&mut buf, &op); + let out = buf.into_string(); + + assert!(out.starts_with("() => ({\n")); + assert!(out.contains("method: 'GET',")); + assert!(out.contains("url: `/ping`,")); + assert!(!out.contains("request:")); + assert!(!out.contains("headers")); + assert!(!out.contains("body:")); + assert!(!out.contains("params:")); + } + + // ── render_request_interface ─────────────────────────────────────────────── + + #[test] + fn request_interface_renders_ref_body_as_nested_alongside_headers() { + let str_ty = string_ty(); + let payload_ref = SchemaType::Ref("CreatePetPayload".into()); + let op = op_with( + "createPet", + HttpMethod::Post, + "/pets", + PlannedRequestContract { + fields: vec![], + headers: vec![ + PlannedHeader { + name: "X-Trace-Id".into(), + optional: false, + ty: &str_ty, + }, + PlannedHeader { + name: "X-Idempotency-Key".into(), + optional: true, + ty: &str_ty, + }, + ], + body: Some(nested_body(&payload_ref, false)), + }, + None, + ); + + let mut buf = Writer::with_capacity(1024); + render_request_interface(&mut buf, &op, "CreatePetParams"); + let out = buf.into_string(); + + assert!(out.contains("export interface CreatePetParams")); + // Ref body keeps its named type nested under the literal `body` slot. + assert!(out.contains("body: CreatePetPayload;")); + // Synthetic `headers` is required when any header is required, optional + // only when all headers are optional. Mixed (one required) ⇒ required. + assert!(out.contains("headers: {")); + // Header names with `-` are quoted via safe_property_name. + assert!(out.contains("'X-Trace-Id': string;")); + assert!(out.contains("'X-Idempotency-Key'?: string;")); + } + + #[test] + fn request_interface_hoists_flat_json_body_properties_to_top_level() { + // Smart-flatten: inline-object bodies surface as top-level fields, + // matching the spec author's intent (loose parameter bag rather than + // a named DTO). + let str_ty = string_ty(); + let bool_ty = SchemaType::Scalar(SchemaScalar::Boolean); + let op = op_with( + "decide", + HttpMethod::Post, + "/decide", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(flat_json_body( + vec![ + body_field("csvImportId", false, &str_ty), + body_field("doImport", false, &bool_ty), + ], + true, + )), + }, + None, + ); + + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "DecideParams"); + let out = buf.into_string(); + + assert!(out.contains("export interface DecideParams")); + assert!(out.contains("csvImportId: string;")); + assert!(out.contains("doImport: boolean;")); + // No nested `body:` field for FlatJson — the properties are hoisted. + assert!(!out.contains("body:")); + } + + #[test] + fn request_interface_marks_nested_body_optional_when_envelope_not_required() { + let payload_ref = SchemaType::Ref("MaybePayload".into()); + let op = op_with( + "savePet", + HttpMethod::Put, + "/pets", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(nested_body(&payload_ref, true)), + }, + None, + ); + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "SavePetParams"); + let out = buf.into_string(); + assert!(out.contains("body?: MaybePayload;")); + } + + #[test] + fn request_interface_marks_headers_optional_when_all_headers_optional() { + let str_ty = string_ty(); + let op = op_with( + "getPet", + HttpMethod::Get, + "/pets/{id}", + PlannedRequestContract { + fields: vec![path_field("id", &str_ty)], + headers: vec![PlannedHeader { + name: "X-Trace-Id".into(), + optional: true, + ty: &str_ty, + }], + body: None, + }, + None, + ); + + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "GetPetParams"); + let out = buf.into_string(); + + // All-optional headers ⇒ the synthetic `headers` field itself is `?:`. + assert!(out.contains("headers?: {")); + } + + #[test] + fn request_interface_omits_headers_block_when_absent() { + let str_ty = string_ty(); + let op = op_with( + "getPet", + HttpMethod::Get, + "/pets/{id}", + PlannedRequestContract { + fields: vec![path_field("id", &str_ty)], + headers: vec![], + body: None, + }, + None, + ); + + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "GetPetParams"); + let out = buf.into_string(); + + assert!(out.contains("id: string;")); + assert!(!out.contains("headers")); + } + + #[test] + fn request_interface_renders_binary_as_blob_or_file_union() { + let str_ty = string_ty(); + let binary = BodyFieldType::Binary; + let op = op_with_multipart_fields_full( + vec![path_field("petId", &str_ty)], // path + vec![], // headers + vec![("avatar", false, &binary)], // form fields + ); + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("export interface OpParams")); + assert!(out.contains("petId: string;")); + assert!(out.contains("avatar: Blob | File;")); + } + + #[test] + fn request_interface_renders_array_of_binary_as_blob_or_file_array() { + let arr_binary = BodyFieldType::ArrayOfBinary; + let op = op_with_multipart_fields_full(vec![], vec![], vec![("galleries", false, &arr_binary)]); + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("galleries: (Blob | File)[];")); + } + + #[test] + fn request_interface_renders_optional_form_field_with_question_mark() { + let scalar = BodyFieldType::Scalar(SchemaScalar::String); + let op = op_with_multipart_fields_full(vec![], vec![], vec![("nickname", true, &scalar)]); + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + // Form fields hoist to top-level — no nested `body:` wrapper. + assert!(out.contains("nickname?: string;")); + assert!(!out.contains("body:")); + } + + #[test] + fn request_interface_renders_mixed_required_form_fields_at_top_level() { + let scalar = BodyFieldType::Scalar(SchemaScalar::String); + let op = op_with_multipart_fields_full( + vec![], + vec![], + vec![("status", false, &scalar), ("nickname", true, &scalar)], + ); + let mut buf = Writer::with_capacity(512); + render_request_interface(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("status: string;")); + assert!(out.contains("nickname?: string;")); + assert!(!out.contains("body:")); + } + + // ── path-template expansion ──────────────────────────────────────────────── + + #[test] + fn write_path_template_expands_every_placeholder() { + let mut buf = Writer::with_capacity(128); + write_path_template_into(&mut buf, "/pets/{petId}/owners/{ownerId}"); + assert_eq!( + buf.into_string(), + "/pets/${encodeURIComponent(petId)}/owners/${encodeURIComponent(ownerId)}" + ); + } + + #[test] + fn write_path_template_leaves_literal_paths_alone() { + let mut buf = Writer::with_capacity(64); + write_path_template_into(&mut buf, "/pets"); + assert_eq!(buf.into_string(), "/pets"); + } + + // ── multipart form-body builder ──────────────────────────────────────────── + + #[test] + fn multipart_builder_renders_required_scalar_as_unguarded_append() { + let scalar = BodyFieldType::Scalar(SchemaScalar::String); + let op = op_with_multipart_fields(vec![ + ("status", false /* optional? */, &scalar), // required + ]); + + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + + // Form fields are destructured directly from `request` (smart-flatten + // hoists them to top-level) and referenced by bare identifier in the + // FormData appends. + assert!(out.contains("const { status } = request;")); + assert!(out.contains("const fd = new FormData();")); + assert!(out.contains("fd.append('status', String(status));")); + assert!(!out.contains("if (status !==")); // required ⇒ no guard + } + + #[test] + fn multipart_builder_renders_optional_scalar_with_undefined_guard() { + let scalar = BodyFieldType::Scalar(SchemaScalar::String); + let op = op_with_multipart_fields(vec![ + ("nickname", true, &scalar), // optional + ]); + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("if (nickname !== undefined) fd.append('nickname', String(nickname));")); + } + + #[test] + fn multipart_builder_renders_required_array_as_for_loop() { + let arr = BodyFieldType::ArrayOfScalar(SchemaScalar::Number); + let op = op_with_multipart_fields(vec![("tagIds", false, &arr)]); + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("for (const v of tagIds) fd.append('tagIds', String(v));")); + assert!(!out.contains("if (tagIds")); // required ⇒ no guard + } + + #[test] + fn multipart_builder_renders_required_binary_without_string_cast() { + let binary = BodyFieldType::Binary; + let op = op_with_multipart_fields(vec![("avatar", false, &binary)]); + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("fd.append('avatar', avatar);")); + assert!(!out.contains("String(avatar)")); + } + + #[test] + fn multipart_builder_renders_array_of_binary_as_for_loop_without_cast() { + let arr_binary = BodyFieldType::ArrayOfBinary; + let op = op_with_multipart_fields(vec![("galleries", false, &arr_binary)]); + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("for (const v of galleries) fd.append('galleries', v);")); + assert!(!out.contains("String(v)")); + } + + // ── url-encoded form-body builder ────────────────────────────────────────── + + #[test] + fn urlencoded_builder_uses_url_search_params_constructor() { + let scalar = BodyFieldType::Scalar(SchemaScalar::String); + let arr = BodyFieldType::ArrayOfScalar(SchemaScalar::Number); + let op = op_with_urlencoded_fields(vec![("status", false, &scalar), ("tagIds", true, &arr)]); + let mut buf = Writer::with_capacity(512); + render_requestful_builder(&mut buf, &op, "OpParams"); + let out = buf.into_string(); + assert!(out.contains("const params = new URLSearchParams();")); + assert!(out.contains("params.append('status', String(status));")); + assert!(out.contains( + "if (tagIds !== undefined) for (const v of tagIds) params.append('tagIds', String(v));" + )); + } +} diff --git a/src/emit/angular/service.rs b/src/emit/angular/service.rs new file mode 100644 index 0000000..74f47d8 --- /dev/null +++ b/src/emit/angular/service.rs @@ -0,0 +1,363 @@ +use std::fmt::Write as _; + +use crate::emit::typescript::{Position, Writer, render_type}; +use crate::ir::canonical::ResponseContent; +use crate::plan::artifact_plan::{PlannedOperation, ServicePlan}; +use crate::plan::naming::{error_interface_name, request_interface_name}; + +use super::imports::render_service_imports; +use super::request::{ + render_error_interface, render_request_interface, render_requestful_builder, + render_zero_arg_builder, +}; + +pub(crate) fn emit_service(service_plan: &ServicePlan<'_>) -> String { + // Each operation produces ~512 bytes (request interface + factory + // triplet + URL/body construction); 2KB floor covers the @Injectable + // header + import block. + let capacity = (service_plan.operations.len() * 512).max(2048); + let mut buffer = Writer::with_capacity(capacity); + + render_service_imports(&mut buffer, &service_plan.operations, "../rest.util"); + buffer.blank_line(); + buffer.line("@Injectable({"); + buffer.line(" providedIn: 'root',"); + buffer.line("})"); + buffer.open_block(&format!("export class {}", service_plan.class_name)); + + // Cache request interface names computed once per operation + let request_names: std::collections::HashMap<&str, String> = service_plan + .operations + .iter() + .filter(|operation| has_request_interface(operation)) + .map(|operation| { + ( + operation.method_name.as_str(), + request_interface_name(&operation.method_name), + ) + }) + .collect(); + + for operation in &service_plan.operations { + buffer.blank_line(); + render_operation_property( + &mut buffer, + operation, + request_names.get(operation.method_name.as_str()), + ); + } + + buffer.close_block(""); + + // Per-operation tail: for each operation, emit its `{Pascal}Params` + // interface (when the operation has any inputs) followed by its + // `{Pascal}Error` interface (when it declares any 4xx/5xx with a JSON + // schema). Per-operation grouping beats kind-grouping when the file + // grows long — a reader searching for "UpdatePet" finds the property, + // its params, and its error map contiguously. + for operation in &service_plan.operations { + let request_name = request_names.get(operation.method_name.as_str()); + let has_errors = !operation.errors.is_empty(); + if request_name.is_none() && !has_errors { + continue; + } + buffer.blank_line(); + if let Some(name) = request_name { + render_request_interface(&mut buffer, operation, name); + } + if has_errors { + if request_name.is_some() { + buffer.blank_line(); + } + let error_name = error_interface_name(&operation.method_name); + render_error_interface(&mut buffer, operation, &error_name); + } + } + + buffer.into_string() +} + +fn render_operation_property( + buffer: &mut Writer, + operation: &PlannedOperation<'_>, + request_name: Option<&String>, +) { + let property_name = &operation.method_name; + + crate::emit::typescript::jsdoc( + buffer, + operation.description.as_deref(), + operation.deprecated, + ); + write!(buffer, "readonly {property_name} = ").unwrap(); + write_response_call_site(buffer, operation.response, request_name); + buffer.push("(\n"); + buffer.indent(); + match request_name { + Some(name) => render_requestful_builder(buffer, operation, name), + None => render_zero_arg_builder(buffer, operation), + } + buffer.dedent(); + buffer.line(");"); +} + +const fn has_request_interface(operation: &PlannedOperation<'_>) -> bool { + !operation.request.fields.is_empty() + || operation.request.body.is_some() + || !operation.request.headers.is_empty() +} + +/// Writes the full helper call prefix into `buffer`. The arity of the +/// operation (does it take a typed `Request`?) and the response variant +/// pick one of four call shapes — explicit at the generator boundary, +/// so the runtime no longer needs the `reqFn.length === 0` probe. +/// +/// Mapping (see docs/superpowers/specs/2026-05-19-request-factory-variants-design.md): +/// +/// | | Requestful | Zero-arg | +/// |----------------|-------------------------------|----------------------------------------| +/// | JSON / void | `requestFactory` | `requestFactory.zeroArg` | +/// | Blob | `requestFactory.blob` | `requestFactory.zeroArg.blob` | +/// | Text | `requestFactory.text` | `requestFactory.zeroArg.text` | +/// | ArrayBuffer | `requestFactory.arrayBuffer` | `requestFactory.zeroArg.arrayBuffer` | +fn write_response_call_site( + buffer: &mut Writer, + response: Option<&ResponseContent>, + request_name: Option<&String>, +) { + let variant = match response { + Some(ResponseContent::Blob) => Some("blob"), + Some(ResponseContent::Text) => Some("text"), + Some(ResponseContent::ArrayBuffer) => Some("arrayBuffer"), + Some(ResponseContent::Json(_)) | None => None, + }; + + match (variant, request_name) { + (Some(kind), Some(request)) => { + write!(buffer, "requestFactory.{kind}<{request}>").unwrap(); + } + (Some(kind), None) => { + write!(buffer, "requestFactory.zeroArg.{kind}").unwrap(); + } + (None, Some(request)) => { + write!(buffer, "requestFactory<{request}, ").unwrap(); + write_response_type(buffer, response); + buffer.push(">"); + } + (None, None) => { + buffer.push("requestFactory.zeroArg<"); + write_response_type(buffer, response); + buffer.push(">"); + } + } +} + +fn write_response_type(buffer: &mut Writer, response: Option<&ResponseContent>) { + match response { + Some(ResponseContent::Json(Some(ty))) => { + render_type(buffer, ty, Position::Standalone); + } + Some(ResponseContent::Json(None)) | None => { + buffer.push("void"); + } + Some(ResponseContent::Blob | ResponseContent::Text | ResponseContent::ArrayBuffer) => { + unreachable!("non-JSON variants handled above"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::canonical::{HttpMethod, ResponseContent}; + use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::plan::artifact_plan::PlannedRequestContract; + use crate::test_support::{op_with, path_field, string_ty}; + + // The four tests below pin the helper expression emitted by + // render_operation_property across every ResponseContent variant. + // JSON uses the bare requestFactory(…); Blob/Text/ArrayBuffer + // use the static-method variant (requestFactory.blob(…) etc.) — + // no Response generic and no { responseKind: '…' } option line. + + fn render_property(op: &PlannedOperation<'_>, request_name: &str) -> String { + let mut buf = Writer::with_capacity(512); + let owned = request_name.to_string(); + render_operation_property(&mut buf, op, Some(&owned)); + buf.into_string() + } + + fn op_with_response_and_path<'a>( + method_name: &str, + path_ty: &'a SchemaType, + response: &'a ResponseContent, + ) -> PlannedOperation<'a> { + op_with( + method_name, + HttpMethod::Get, + "/x/{id}", + PlannedRequestContract { + fields: vec![path_field("id", path_ty)], + headers: vec![], + body: None, + }, + Some(response), + ) + } + + #[test] + fn request_factory_call_uses_bare_helper_for_json_response() { + let str_ty = string_ty(); + let json = ResponseContent::Json(Some(SchemaType::Scalar(SchemaScalar::String))); + let op = op_with_response_and_path("listPets", &str_ty, &json); + let out = render_property(&op, "ListPetsParams"); + + assert!( + out.contains("requestFactory"), + "expected bare requestFactory, got:\n{out}" + ); + assert!( + !out.contains("requestFactory.blob") + && !out.contains("requestFactory.text") + && !out.contains("requestFactory.arrayBuffer"), + "expected no static-method variant for JSON response, got:\n{out}" + ); + assert!( + !out.contains("responseKind"), + "expected no responseKind option for JSON response, got:\n{out}" + ); + } + + #[test] + fn request_factory_call_uses_blob_variant_for_blob_response() { + let str_ty = string_ty(); + let op = op_with_response_and_path("download", &str_ty, &ResponseContent::Blob); + let out = render_property(&op, "DownloadParams"); + + assert!( + out.contains("requestFactory.blob"), + "expected requestFactory.blob(…) call, got:\n{out}" + ); + assert!( + !out.contains(", Blob>"), + "expected no Response generic for blob variant (Raw is fixed), got:\n{out}" + ); + assert!( + !out.contains("responseKind"), + "expected no responseKind option, got:\n{out}" + ); + } + + #[test] + fn request_factory_call_uses_text_variant_for_text_response() { + let str_ty = string_ty(); + let op = op_with_response_and_path("rawConfig", &str_ty, &ResponseContent::Text); + let out = render_property(&op, "RawConfigParams"); + + assert!( + out.contains("requestFactory.text"), + "expected requestFactory.text(…) call, got:\n{out}" + ); + assert!( + !out.contains(", string>"), + "expected no Response generic for text variant, got:\n{out}" + ); + assert!( + !out.contains("responseKind"), + "expected no responseKind option, got:\n{out}" + ); + } + + #[test] + fn request_factory_call_uses_array_buffer_variant_for_array_buffer_response() { + let str_ty = string_ty(); + let op = op_with_response_and_path("fetch", &str_ty, &ResponseContent::ArrayBuffer); + let out = render_property(&op, "FetchParams"); + + assert!( + out.contains("requestFactory.arrayBuffer"), + "expected requestFactory.arrayBuffer(…) call, got:\n{out}" + ); + assert!( + !out.contains(", ArrayBuffer>"), + "expected no Response generic for arrayBuffer variant, got:\n{out}" + ); + assert!( + !out.contains("responseKind"), + "expected no responseKind option, got:\n{out}" + ); + } + + // The four tests below pin the zero-arg call site emitted when the + // operation has no inputs/headers/body. Codegen routes them through + // the dedicated `requestFactory.zeroArg(.kind?)` entry points instead + // of relying on a runtime `reqFn.length === 0` probe. + + fn op_with_response_no_request<'a>( + method_name: &str, + response: &'a ResponseContent, + ) -> PlannedOperation<'a> { + op_with( + method_name, + HttpMethod::Get, + "/x", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: None, + }, + Some(response), + ) + } + + fn render_zero_arg_property(op: &PlannedOperation<'_>) -> String { + let mut buf = Writer::with_capacity(512); + render_operation_property(&mut buf, op, None); + buf.into_string() + } + + #[test] + fn request_factory_zero_arg_json_uses_zero_arg_helper() { + let json = ResponseContent::Json(Some(SchemaType::Scalar(SchemaScalar::String))); + let op = op_with_response_no_request("listPets", &json); + let out = render_zero_arg_property(&op); + + assert!( + out.contains("requestFactory.zeroArg"), + "expected requestFactory.zeroArg(…) for zero-arg JSON, got:\n{out}" + ); + } + + #[test] + fn request_factory_zero_arg_blob_uses_nested_helper() { + let op = op_with_response_no_request("download", &ResponseContent::Blob); + let out = render_zero_arg_property(&op); + + assert!( + out.contains("requestFactory.zeroArg.blob"), + "expected requestFactory.zeroArg.blob(…) for zero-arg blob, got:\n{out}" + ); + } + + #[test] + fn request_factory_zero_arg_text_uses_nested_helper() { + let op = op_with_response_no_request("rawConfig", &ResponseContent::Text); + let out = render_zero_arg_property(&op); + + assert!( + out.contains("requestFactory.zeroArg.text"), + "expected requestFactory.zeroArg.text(…) for zero-arg text, got:\n{out}" + ); + } + + #[test] + fn request_factory_zero_arg_array_buffer_uses_nested_helper() { + let op = op_with_response_no_request("fetch", &ResponseContent::ArrayBuffer); + let out = render_zero_arg_property(&op); + + assert!( + out.contains("requestFactory.zeroArg.arrayBuffer"), + "expected requestFactory.zeroArg.arrayBuffer(…) for zero-arg arrayBuffer, got:\n{out}" + ); + } +} diff --git a/src/emit/mod.rs b/src/emit/mod.rs new file mode 100644 index 0000000..aa81754 --- /dev/null +++ b/src/emit/mod.rs @@ -0,0 +1,201 @@ +pub(crate) mod angular; +pub(crate) mod model; +pub(crate) mod typescript; + +#[cfg(test)] +mod typescript_tests; + +/// Public path of the generated TypeScript model artifact. +pub(crate) const MODEL_ARTIFACT_PATH: &str = "model.generated.ts"; + +/// Compile-time crate version for the do-not-edit banner. Sourced from +/// Cargo.toml (the same value `package.json:3` mirrors). +const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Top-of-file banner prepended to every generated artifact. +/// Shape: +/// // Generated by openapi-ng vX.Y.Z +/// // Source: +/// // DO NOT EDIT — regenerate with `openapi-ng generate ...` +/// +/// Three lines so editors that wrap the first line do not hide the +/// generator name. The `// DO NOT EDIT` line carries the en-dash so +/// consumers searching for `eslint-plugin-no-edit-generated`-style +/// patterns find a stable anchor. +/// +/// Computed once per pipeline run in `pipeline::run_pipeline` and threaded +/// into every emit call site as `banner: &str`, so a large spec's N +/// artifacts share one allocation instead of paying `format!` per file. +/// +/// Security: absolute paths that resolve INSIDE the current working +/// directory are relativised against CWD before being embedded. This +/// prevents programmatic consumers (and the CLI, since the JS wrapper +/// now passes the raw user input) from leaking local filesystem layout +/// (e.g. `/Users/alice/work/spec.yaml`) into committed artifacts. +/// Paths outside CWD are left absolute so the leak is bounded to the +/// "spec lives outside the project root" case, and on a missing CWD +/// we fall back to the input path verbatim. +pub(crate) fn render_generated_banner(source_path: &str) -> String { + let display = relativise_against_cwd(source_path); + format!( + "// Generated by openapi-ng v{GENERATOR_VERSION}\n// Source: {display}\n// DO NOT EDIT — regenerate with `openapi-ng generate`\n\n" + ) +} + +/// Relativise an input path against the current working directory. +/// Returns the input unchanged when: +/// - the input is not absolute (already relative — caller's call), or +/// - `current_dir()` is unavailable (e.g. CWD was deleted), or +/// - `strip_prefix` fails (path lives outside CWD). +/// +/// Uses forward slashes on the result so banner format stays +/// platform-independent and matches `pipeline::generate`'s display-path +/// normalization. +fn relativise_against_cwd(source_path: &str) -> String { + use std::path::Path; + let input = Path::new(source_path); + if !input.is_absolute() { + return source_path.to_owned(); + } + let Ok(cwd) = std::env::current_dir() else { + return source_path.to_owned(); + }; + input.strip_prefix(&cwd).map_or_else( + |_| source_path.to_owned(), + |rel| rel.to_string_lossy().replace('\\', "/"), + ) +} + +#[cfg(test)] +mod banner_tests { + use super::render_generated_banner; + use proptest::prelude::*; + + #[test] + fn banner_includes_generator_name_version_source_and_do_not_edit_marker() { + let banner = render_generated_banner("./spec.yaml"); + assert!(banner.contains("Generated by openapi-ng v")); + assert!(banner.contains("Source: ./spec.yaml")); + assert!(banner.contains("DO NOT EDIT")); + assert!(banner.contains("openapi-ng generate")); + assert!( + banner.ends_with("\n\n"), + "banner must terminate with a blank line" + ); + } + + #[test] + fn banner_version_matches_cargo_pkg_version() { + let banner = render_generated_banner("ignored"); + let expected = env!("CARGO_PKG_VERSION"); + assert!(banner.contains(&format!("v{expected}"))); + } + + /// Security regression: when given an absolute path that lives inside + /// the current working directory, the banner must render the relative + /// form so the absolute CWD prefix never lands in a committed + /// artifact. Mirrors the JS-side `relativizeForBanner` that previously + /// did this at the CLI boundary; the Rust side now owns it so + /// programmatic consumers (`generate({ inputPath: '/abs/...' })`) get + /// the same treatment as the CLI. + #[test] + fn banner_relativises_paths_inside_cwd() { + let cwd = std::env::current_dir().unwrap(); + let absolute_inside = cwd.join("test/fixtures/petstore-minimal.openapi.yaml"); + let banner = render_generated_banner(&absolute_inside.to_string_lossy()); + assert!( + banner.contains("Source: test/fixtures/petstore-minimal.openapi.yaml"), + "expected relative path in banner, got: {banner}" + ); + let cwd_prefix = cwd.to_string_lossy().replace('\\', "/"); + assert!( + !banner.contains(&*cwd_prefix), + "absolute CWD prefix leaked into banner: {banner}" + ); + } + + /// Paths outside CWD are left absolute — `strip_prefix` fails for + /// them, and rewriting them would break the read-side path passed + /// elsewhere if anything ever shared this string. Keeps the + /// "leak limited to spec outside project root" semantics that the + /// old JS function documented. + #[test] + fn banner_keeps_paths_outside_cwd_absolute() { + // Pick a path that's guaranteed not to be under CWD across all + // platforms the project supports. On Unix `/nonexistent-outside-cwd` + // is absolute; on Windows the leading `/` still makes it absolute + // relative to the current drive — and it won't share the CWD prefix + // unless someone runs cargo from `/`. + let outside = "/nonexistent-outside-cwd/external.yaml"; + let banner = render_generated_banner(outside); + assert!( + banner.contains(&format!("Source: {outside}")), + "expected absolute path preserved in banner, got: {banner}" + ); + } + + /// Already-relative paths must pass through untouched. The banner + /// must not turn `./spec.yaml` into an empty string or prepend `./` + /// in surprising ways. + #[test] + fn banner_passes_relative_paths_through() { + let banner = render_generated_banner("./spec.yaml"); + assert!( + banner.contains("Source: ./spec.yaml"), + "expected relative input preserved verbatim, got: {banner}" + ); + } + + /// Rust-side equivalent of the banner-strip regex used by the JS + /// snapshot test (`BANNER_RE` in __test__/generate.snapshot.spec.ts and + /// scripts/regen-snapshots.mjs). Kept here so a `proptest` round-trip + /// across the full source-path input space surfaces drift between the + /// Rust banner writer and the JS strip regex. + fn strip_banner(input: &str) -> Option<&str> { + let first = input.find('\n')?; + let line1 = &input[..first]; + if !line1.starts_with("// Generated by openapi-ng v") { + return None; + } + let after_line1 = &input[first + 1..]; + let second = after_line1.find('\n')?; + let line2 = &after_line1[..second]; + if !line2.starts_with("// Source: ") { + return None; + } + let after_line2 = &after_line1[second + 1..]; + let third = after_line2.find('\n')?; + let line3 = &after_line2[..third]; + if !line3.starts_with("// DO NOT EDIT") { + return None; + } + // Banner ends with a blank line (\n\n), so the next byte must be a + // newline before the artifact body begins. + let after_line3 = &after_line2[third + 1..]; + if !after_line3.starts_with('\n') { + return None; + } + Some(&after_line3[1..]) + } + + proptest! { + /// Any non-newline source path produces a 3-line banner that + /// `strip_banner` can remove, leaving the original body intact. This + /// pins the shape of `render_generated_banner` against the strip + /// regex on the JS side: when this proptest fails, the JS regex + /// almost certainly needs to be updated too. + #[test] + fn render_then_strip_banner_returns_original_body( + source in "[^\n]{0,64}", + body in proptest::string::string_regex(".{0,128}").unwrap(), + ) { + let banner = render_generated_banner(&source); + let combined = format!("{banner}{body}"); + let stripped = strip_banner(&combined) + .ok_or_else(|| proptest::test_runner::TestCaseError::fail( + format!("strip_banner could not consume the rendered banner for source={source:?}") + ))?; + prop_assert_eq!(stripped, body.as_str()); + } + } +} diff --git a/src/emit/model/emit_ts_models.rs b/src/emit/model/emit_ts_models.rs new file mode 100644 index 0000000..ad9aefa --- /dev/null +++ b/src/emit/model/emit_ts_models.rs @@ -0,0 +1,224 @@ +use std::fmt::Write as _; + +use crate::{ + emit::typescript::{self as ts, Position, Writer, render_type, write_import_line}, + ir::{ + canonical::ModelSymbol, + schema::{SchemaProperty, SchemaType}, + }, + plan::artifact_plan::ResolvedMappedType, +}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) fn emit_model( + model_symbols: &[ModelSymbol], + mapped_types: &[ResolvedMappedType<'_>], +) -> String { + // Heuristic: each named model symbol expands to ~256 bytes of TS once + // mapped imports are factored in. Pre-sizing the buffer avoids 4-5 + // reallocs for petstore-rich-sized specs. + let capacity = (model_symbols.len() * 256).max(1024); + let mut output = Writer::with_capacity(capacity); + + emit_mapped_imports(mapped_types, &mut output); + + let mapped_by_name: BTreeMap<&str, &ResolvedMappedType<'_>> = + mapped_types.iter().map(|m| (m.schema, m)).collect(); + + // `emit_mapped_imports` writes exactly one line per `mapped_type` (either an + // import or a re-export) — so non-empty input is sufficient to know we + // emitted something and need a blank-line separator before the model body. + if !mapped_types.is_empty() && !model_symbols.is_empty() { + output.blank_line(); + } + + let mut first = true; + + for symbol in model_symbols { + let name = symbol.name.as_ref(); + if let Some(mapped_type) = mapped_by_name.get(name) { + // Re-export self-aliases are emitted as `export type { ... } from + // '...'` in the imports block above — skip the placeholder alias + // entirely (`export type X = X;` would collide with the imported + // binding). + if is_self_alias(mapped_type) { + continue; + } + if !first { + output.blank_line(); + } + first = false; + emit_mapped_placeholder(name, mapped_type, &mut output); + continue; + } + + if !first { + output.blank_line(); + } + first = false; + + match &symbol.body { + SchemaType::InlineObject { properties } => emit_interface( + name, + symbol.description.as_deref(), + symbol.deprecated, + properties, + &mut output, + ), + SchemaType::StringLiterals { values } => emit_enum( + name, + symbol.description.as_deref(), + symbol.deprecated, + values, + &mut output, + ), + other => emit_type_alias( + name, + symbol.description.as_deref(), + symbol.deprecated, + other, + &mut output, + ), + } + } + + let mut rendered = output.into_string(); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered +} + +/// A mapped type is a *self-alias* when the binding it introduces into +/// the file (the alias if set, otherwise the imported type name) already +/// matches the schema name. In that case the regular `import type { Y as +/// X } from '...';` + `export type X = X;` pair would collide on the +/// `X` identifier, so we collapse to a single `export type { Y as X } +/// from '...';` re-export and skip the alias placeholder. +fn is_self_alias(mapped_type: &ResolvedMappedType<'_>) -> bool { + let binding_name = mapped_type + .alias + .as_deref() + .unwrap_or_else(|| mapped_type.ty.as_ref()); + binding_name == mapped_type.schema +} + +fn emit_mapped_imports(mapped_types: &[ResolvedMappedType<'_>], output: &mut Writer) { + // Group by import path, partitioning each path's entries into + // re-exports and regular imports so the emitted block has a stable + // ordering: regular imports first (deterministic per-path), then + // re-exports. + let mut imports_by_path = BTreeMap::<&str, BTreeSet<(&str, Option<&str>)>>::new(); + let mut reexports_by_path = BTreeMap::<&str, BTreeSet<(&str, &str)>>::new(); + + for mapped_type in mapped_types { + if is_self_alias(mapped_type) { + // `export type { ty as schema }` — when `ty == schema`, drop the + // alias rename so the line stays `export type { X } from '...'`. + let imported = mapped_type.ty.as_ref(); + let exported_as = mapped_type.schema; + reexports_by_path + .entry(mapped_type.import.as_ref()) + .or_default() + .insert((imported, exported_as)); + } else { + imports_by_path + .entry(mapped_type.import.as_ref()) + .or_default() + .insert((mapped_type.ty.as_ref(), mapped_type.alias.as_deref())); + } + } + + for (import_path, type_names) in &imports_by_path { + write_import_line(output, type_names.iter().copied(), import_path, true); + } + + for (import_path, entries) in &reexports_by_path { + write_reexport_line(output, entries, import_path); + } +} + +fn write_reexport_line(output: &mut Writer, entries: &BTreeSet<(&str, &str)>, import_path: &str) { + output.push("export type { "); + let mut first = true; + for (imported, exported_as) in entries { + if !first { + output.push(", "); + } + first = false; + output.push(imported); + if imported != exported_as { + output.push(" as "); + output.push(exported_as); + } + } + output.push(" } from '"); + output.push(import_path); + output.push("';\n"); +} + +fn emit_mapped_placeholder(name: &str, mapped_type: &ResolvedMappedType<'_>, output: &mut Writer) { + let native_type = mapped_type + .alias + .as_deref() + .unwrap_or_else(|| mapped_type.ty.as_ref()); + ts::type_alias(output, name, None, false, native_type); +} + +fn emit_type_alias( + name: &str, + description: Option<&str>, + deprecated: bool, + target: &SchemaType, + output: &mut Writer, +) { + ts::jsdoc(output, description, deprecated); + write!(output, "export type {name} = ").unwrap(); + render_type(output, target, Position::Standalone); + output.push(";\n"); +} + +fn emit_enum( + name: &str, + description: Option<&str>, + deprecated: bool, + values: &[String], + output: &mut Writer, +) { + ts::string_union(output, name, description, deprecated, values); +} + +fn emit_interface( + name: &str, + description: Option<&str>, + deprecated: bool, + properties: &[SchemaProperty], + output: &mut Writer, +) { + if properties.is_empty() { + ts::type_alias( + output, + name, + description, + deprecated, + "Record", + ); + return; + } + ts::interface_block( + output, + name, + description, + deprecated, + properties.iter().map(|p| { + ( + p.name.as_ref(), + !p.required, + &p.ty, + p.description.as_deref(), + p.deprecated, + ) + }), + true, + ); +} diff --git a/src/emit/model/mod.rs b/src/emit/model/mod.rs new file mode 100644 index 0000000..d55d981 --- /dev/null +++ b/src/emit/model/mod.rs @@ -0,0 +1,168 @@ +pub(crate) mod emit_ts_models; + +#[cfg(test)] +mod tests { + use super::emit_ts_models; + use crate::{ + ir::{ + canonical::ModelSymbol, + schema::{SchemaScalar, SchemaType}, + }, + plan::artifact_plan::ResolvedMappedType, + test_support::property, + }; + + #[test] + fn emit_model_renders_aliases_enums_interfaces_and_inline_objects() { + let model_symbols = vec![ + ModelSymbol { + name: "AliasId".into(), + description: None, + deprecated: false, + body: SchemaType::Scalar(SchemaScalar::String), + }, + ModelSymbol { + name: "PetStatus".into(), + description: None, + deprecated: false, + body: SchemaType::StringLiterals { + values: vec!["available".to_string(), "adopted".to_string()], + }, + }, + ModelSymbol { + name: "Pet".into(), + description: None, + deprecated: false, + body: SchemaType::InlineObject { + properties: vec![ + property("id", true, SchemaType::Ref("AliasId".into())), + property( + "profile", + false, + SchemaType::InlineObject { + properties: vec![ + property( + "displayName", + true, + SchemaType::Scalar(SchemaScalar::String), + ), + property( + "tags", + true, + SchemaType::Array(Box::new(SchemaType::Scalar(SchemaScalar::String))), + ), + ], + }, + ), + ], + }, + }, + ]; + + let output = emit_ts_models::emit_model(&model_symbols, &[]); + + assert!(output.contains("export type AliasId = string;")); + // 2 short literals stay inline (joined width well under ENUM_INLINE_WIDTH). + assert!(output.contains("export type PetStatus = 'available' | 'adopted';")); + assert!(output.contains("export interface Pet {\n")); + assert!(output.contains(" id: AliasId;\n")); + assert!(output.contains(" profile?: {\n")); + assert!(output.contains("displayName: string;")); + assert!(output.contains("tags: string[];")); + } + + #[test] + fn emit_model_renders_mapped_type_aliases_for_renamed_target() { + // schema=UserId, type=ExternalUserId, alias=Nickname — the binding + // brought into scope is `Nickname`, which is distinct from the schema + // name. The placeholder alias still needs to redirect `UserId` to + // the in-scope binding. + let model_symbols = vec![ModelSymbol { + name: "UserId".into(), + description: None, + deprecated: false, + body: SchemaType::Scalar(SchemaScalar::String), + }]; + + let output = emit_ts_models::emit_model( + &model_symbols, + &[ResolvedMappedType { + schema: "UserId", + import: "./shared/user-id".into(), + ty: "ExternalUserId".into(), + alias: Some("Nickname".into()), + }], + ); + + assert!(output.contains("import type { ExternalUserId as Nickname }")); + assert!(output.contains("export type UserId = Nickname;")); + } + + #[test] + fn emit_model_uses_reexport_for_self_alias_to_avoid_identifier_collision() { + // schema=UserId, type=ExternalUserId, alias=UserId — the binding + // would otherwise be both imported as `UserId` AND aliased to + // `UserId` in the same file (`export type UserId = UserId;`), a + // duplicate-identifier error. The emitter sidesteps this by + // collapsing to a single `export type { ExternalUserId as UserId } + // from './shared/user-id';` re-export. + let model_symbols = vec![ModelSymbol { + name: "UserId".into(), + description: None, + deprecated: false, + body: SchemaType::Scalar(SchemaScalar::String), + }]; + + let output = emit_ts_models::emit_model( + &model_symbols, + &[ResolvedMappedType { + schema: "UserId", + import: "./shared/user-id".into(), + ty: "ExternalUserId".into(), + alias: Some("UserId".into()), + }], + ); + + assert!( + output.contains("export type { ExternalUserId as UserId } from './shared/user-id';"), + "expected re-export shape, got:\n{output}" + ); + assert!( + !output.contains("import type"), + "self-alias case should not emit a separate `import type` line" + ); + assert!( + !output.contains("export type UserId = UserId;"), + "self-alias case should not emit the broken placeholder alias" + ); + } + + #[test] + fn emit_model_uses_reexport_when_schema_matches_imported_type_directly() { + // schema=UserId, type=UserId, no alias — same identifier collision + // as above, just expressed without the alias field. The emitter + // drops the `as UserId` because it would be a no-op rename. + let model_symbols = vec![ModelSymbol { + name: "UserId".into(), + description: None, + deprecated: false, + body: SchemaType::Scalar(SchemaScalar::String), + }]; + + let output = emit_ts_models::emit_model( + &model_symbols, + &[ResolvedMappedType { + schema: "UserId", + import: "./shared/user-id".into(), + ty: "UserId".into(), + alias: None, + }], + ); + + assert!( + output.contains("export type { UserId } from './shared/user-id';"), + "expected bare re-export shape, got:\n{output}" + ); + assert!(!output.contains(" as UserId")); + } +} diff --git a/src/emit/typescript.rs b/src/emit/typescript.rs new file mode 100644 index 0000000..5b8753d --- /dev/null +++ b/src/emit/typescript.rs @@ -0,0 +1,611 @@ +//! Unified emit-layer writer. +//! +//! `Writer` is the single output engine for every emitter (TS models, +//! Angular services). It owns the `String` buffer, tracks indent +//! depth, and exposes both raw output primitives (`push`, `line`, +//! `block`) and TypeScript-shaped helpers (`jsdoc`, +//! `interface_block`, `type_alias`, `string_union`, `import_block`, +//! `render_type`). Folding everything into one module collapses the +//! prior split across `CodeBuffer` (buffer engine), `primitives` +//! (interface/type-alias/import helpers), and `ts_renderer` +//! (type-expression rendering). +//! +//! Two policies that used to leak across modules now live exactly once: +//! * **Parenthesization** — `render_type` consults `needs_parens` with +//! a single `Position` enum (standalone / composition / array-item). +//! * **Indentation** — `Writer.indent_cache` is the only ratchet. +//! +//! Most renderers take `&mut Writer` and append directly. The free +//! functions below (`safe_property_name`, `render_type_reference`) +//! exist for the few callers that need a standalone `String` without +//! owning a `Writer`. + +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ir::canonical::BodyFieldType; +use crate::ir::identifier::is_valid_identifier; +use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; +use crate::wln; + +/// Width budget below which a top-level string union renders on a single line. +/// Counts the joined `'a' | 'b' | 'c'` form, not the `export type X = ` prefix. +/// Matches prettier's default `printWidth: 80`. +const ENUM_INLINE_WIDTH: usize = 80; + +/// Width budget for a single-line `import { ... } from '...';` statement. +/// Lines that would exceed this when joined fall back to a multi-line +/// form (one identifier per indented line) so subsequent formatter runs +/// don't re-wrap the file and produce non-empty diffs on every regen. +const IMPORT_INLINE_WIDTH: usize = 100; + +// ── Buffer engine ──────────────────────────────────────────────────────────── + +/// Indent-aware string writer used by every emit target. Tracks line-start +/// state so consecutive `push` calls share an indent prefix without the +/// caller threading it explicitly. +#[derive(Debug, Default)] +pub(crate) struct Writer { + buf: String, + indent_cache: String, + indent_level: usize, + line_start: bool, + last_was_blank: bool, +} + +impl Writer { + pub(crate) fn with_capacity(capacity: usize) -> Self { + Self { + buf: String::with_capacity(capacity), + indent_cache: String::new(), + indent_level: 0, + line_start: true, + last_was_blank: false, + } + } + + pub(crate) fn push(&mut self, value: &str) { + // Fast path: mid-line, no embedded newline — the common case during + // type/expression emission ("(", ", ", identifier tokens). Skips the + // indent bookkeeping and `find('\n')` loop below. Slow path still + // handles every state transition (indent emission, blank-line tracking, + // multi-line literals). + if !self.line_start && !value.contains('\n') { + if !value.is_empty() { + self.buf.push_str(value); + self.last_was_blank = false; + } + return; + } + + let mut rest = value; + while !rest.is_empty() { + if self.line_start { + if let Some(stripped) = rest.strip_prefix('\n') { + self.buf.push('\n'); + self.last_was_blank = true; + rest = stripped; + continue; + } + self.write_indent(); + } + + if let Some(pos) = rest.find('\n') { + self.buf.push_str(&rest[..=pos]); + let line_had_content = pos > 0; + rest = &rest[pos + 1..]; + self.line_start = true; + if line_had_content { + self.last_was_blank = false; + } + } else { + self.buf.push_str(rest); + self.line_start = false; + self.last_was_blank = false; + break; + } + } + } + + pub(crate) fn line(&mut self, value: &str) { + let was_empty = value.is_empty() && self.line_start; + self.push(value); + self.buf.push('\n'); + self.line_start = true; + if was_empty { + self.last_was_blank = true; + } + } + + pub(crate) fn blank_line(&mut self) { + if self.buf.is_empty() { + return; + } + + if self.last_was_blank { + return; + } + + if !self.buf.ends_with('\n') { + self.buf.push('\n'); + } + + self.buf.push('\n'); + self.line_start = true; + self.last_was_blank = true; + } + + pub(crate) fn open_block(&mut self, header: &str) { + if header.is_empty() { + self.line("{"); + } else { + // Infallible writer; sidestep `std::fmt::Write` so we don't drag in + // an `.unwrap()` for an error the buffer never produces. + self.push(header); + self.push(" {"); + self.buf.push('\n'); + self.line_start = true; + self.last_was_blank = false; + } + self.indent(); + } + + pub(crate) fn close_block(&mut self, suffix: &str) { + self.dedent(); + if suffix.is_empty() { + self.line("}"); + } else { + self.push("}"); + self.push(suffix); + self.buf.push('\n'); + self.line_start = true; + self.last_was_blank = false; + } + } + + pub(crate) fn into_string(self) -> String { + self.buf + } + + pub(crate) fn indent(&mut self) { + self.indent_level += 1; + self.indent_cache.push_str(" "); + } + + pub(crate) fn dedent(&mut self) { + self.indent_level = self + .indent_level + .checked_sub(1) + .expect("over-dedent in emitter"); + let new_len = self.indent_level * 2; + self.indent_cache.truncate(new_len); + } + + fn write_indent(&mut self) { + self.buf.push_str(&self.indent_cache); + self.line_start = false; + } +} + +impl std::fmt::Write for Writer { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.push(s); + Ok(()) + } +} + +// ── Identifier escaping ────────────────────────────────────────────────────── + +/// Quote a property name when it isn't a valid bare TS identifier. +/// +/// Reserved words like `class` or `default` are valid in property position in +/// TypeScript (an interface property is `PropertyName`, which accepts any +/// `IdentifierName`), so we only quote when the source name contains +/// characters that would make it syntactically invalid bare: anything other +/// than the `[A-Za-z_$][A-Za-z0-9_$]*` shape (digits-first, kebab-case, +/// dotted, whitespace, etc.). Quoting uses single quotes with backslash +/// escapes, matching `write_string_literal`. +pub(crate) fn safe_property_name(name: &str) -> Cow<'_, str> { + if is_valid_identifier(name) { + Cow::Borrowed(name) + } else { + let mut out = String::with_capacity(name.len() + 2); + write_string_literal(&mut out, name); + Cow::Owned(out) + } +} + +// ── TS-shape helpers ───────────────────────────────────────────────────────── + +/// Emit a JSDoc block for the given description, if any. The description +/// may be multi-line (e.g. when OpenAPI `summary` and `description` are +/// merged with a blank-line separator); each line becomes ` * `. +/// Emits nothing when description is None or empty after trimming AND +/// `deprecated` is false. When `deprecated` is true an `@deprecated` tag +/// line is added (after the description body if both are present) so the +/// emitted output surfaces the deprecation marker to IDE tooltips and +/// linters at the call site. +pub(crate) fn jsdoc(out: &mut Writer, description: Option<&str>, deprecated: bool) { + let trimmed = description.map(str::trim_end).filter(|s| !s.is_empty()); + if trimmed.is_none() && !deprecated { + return; + } + out.line("/**"); + if let Some(text) = trimmed { + for line in text.lines() { + let body = line.trim_end(); + if body.is_empty() { + out.line(" *"); + } else { + let escaped = body.replace("*/", "*\\/"); + wln!(out, " * {escaped}"); + } + } + } + if deprecated { + out.line(" * @deprecated"); + } + out.line(" */"); +} + +/// Emit an `interface` block with the given properties. +/// Properties are tuples of `(name, optional, ty, description, deprecated)`. +/// Nullability is folded into `ty` (`SchemaType::Nullable(...)`) rather than +/// carried alongside it — one carrier for the whole IR. The trailing +/// `deprecated` flag emits a `@deprecated` JSDoc tag above the property +/// declaration when the source schema's `deprecated: true` is set. +pub(crate) fn interface_block<'a>( + out: &mut Writer, + name: &str, + description: Option<&str>, + deprecated: bool, + properties: impl IntoIterator, bool)>, + exported: bool, +) { + jsdoc(out, description, deprecated); + let keyword = if exported { + "export interface " + } else { + "interface " + }; + out.open_block(&format!("{keyword}{name}")); + for (prop_name, optional, ty, prop_description, prop_deprecated) in properties { + jsdoc(out, prop_description, prop_deprecated); + write_property_declaration(out, prop_name, optional, ty); + out.push(";\n"); + } + out.close_block(""); +} + +/// Emit `export type {name} = {rhs};` with an optional JSDoc header. +pub(crate) fn type_alias( + out: &mut Writer, + name: &str, + description: Option<&str>, + deprecated: bool, + rhs: &str, +) { + jsdoc(out, description, deprecated); + wln!(out, "export type {name} = {rhs};"); +} + +/// Emit a string-literal union type, collapsing to one line when short. +pub(crate) fn string_union( + out: &mut Writer, + name: &str, + description: Option<&str>, + deprecated: bool, + values: &[String], +) { + jsdoc(out, description, deprecated); + + // Cheap upper bound on the joined `'a' | 'b' | 'c'` length: each value + // contributes its byte length plus the two quote characters, separated + // by ` | `. UTF-8 byte length over-counts visual width for non-ASCII + // identifiers, which is fine — the budget exists to keep lines short, + // and over-counting only ever forces an additional wrap. + let separator_total = values.len().saturating_sub(1) * " | ".len(); + let quoted_total: usize = values.iter().map(|v| v.len() + 2).sum(); + let joined_width = separator_total + quoted_total; + + if joined_width <= ENUM_INLINE_WIDTH { + let mut inline = String::with_capacity(joined_width); + for (index, value) in values.iter().enumerate() { + if index > 0 { + inline.push_str(" | "); + } + write_string_literal(&mut inline, value); + } + wln!(out, "export type {name} = {inline};"); + return; + } + + wln!(out, "export type {name} ="); + out.indent(); + let last = values.len().saturating_sub(1); + for (index, value) in values.iter().enumerate() { + let suffix = if index == last { ";" } else { "" }; + let mut literal = String::with_capacity(value.len() + 2); + write_string_literal(&mut literal, value); + wln!(out, "| {literal}{suffix}"); + } + out.dedent(); +} + +/// Emit one `import [type] { ... } from '...';` line per path entry. +/// Names within each path are emitted in iteration order (callers should +/// pass a `BTreeSet` for stable output). +pub(crate) fn import_block( + out: &mut Writer, + by_path: &BTreeMap<&str, BTreeSet<&str>>, + type_only: bool, +) { + for (path, names) in by_path { + write_import_line(out, names.iter().map(|n| (*n, None)), path, type_only); + } +} + +/// Write a single `import [type] { name [as alias], ... } from 'path';` +/// statement. Folds the 2 sites that duplicate this `format!` shape +/// (mapped-type imports, service type imports). +/// +/// When the single-line form would exceed `IMPORT_INLINE_WIDTH`, the +/// statement is emitted multi-line — one identifier per indented line, +/// closing `} from '...';` on its own line — so that prettier-shaped +/// consumer formatters don't re-wrap on the first save and regenerate +/// always produces an empty diff. +pub(crate) fn write_import_line<'a>( + out: &mut Writer, + names: impl IntoIterator)>, + path: &str, + type_only: bool, +) { + let prefix = if type_only { + "import type { " + } else { + "import { " + }; + let suffix_len = " } from '".len() + path.len() + "';".len(); + // Buffer the (name, alias) pairs so we can measure the joined width + // before committing to inline vs multi-line. Names are short + // identifiers; the allocation is tiny in practice. + let entries: Vec<(&'a str, Option<&'a str>)> = names.into_iter().collect(); + + let names_width: usize = entries + .iter() + .map(|(name, alias)| name.len() + alias.map_or(0, |a| " as ".len() + a.len())) + .sum(); + let separators_width = entries.len().saturating_sub(1) * ", ".len(); + let joined_width = prefix.len() + names_width + separators_width + suffix_len; + + if joined_width <= IMPORT_INLINE_WIDTH || entries.len() <= 1 { + out.push(prefix); + let mut first = true; + for (name, alias) in &entries { + if !first { + out.push(", "); + } + first = false; + out.push(name); + if let Some(alias) = alias { + out.push(" as "); + out.push(alias); + } + } + out.push(" } from '"); + out.push(path); + out.push("';\n"); + return; + } + + // Multi-line form: one identifier per indented line, trailing comma + // on every entry (matches prettier's wrap style so the first + // formatter pass on a consumer's checkout is a no-op). + out.push(if type_only { + "import type {\n" + } else { + "import {\n" + }); + out.indent(); + for (name, alias) in &entries { + out.push(name); + if let Some(alias) = alias { + out.push(" as "); + out.push(alias); + } + out.push(",\n"); + } + out.dedent(); + out.push("} from '"); + out.push(path); + out.push("';\n"); +} + +// ── Type-expression rendering ──────────────────────────────────────────────── + +/// Syntactic position of a `SchemaType` reference. The position decides +/// whether a composite child needs to be parenthesized so that the +/// surrounding operator binds correctly. Centralising this in one place +/// removes the 3-way duplication we used to keep across separate +/// `render_type_reference` / `render_wrapped_type_reference` / +/// `render_array_item_reference` functions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Position { + /// Standalone reference (top-level type alias RHS, property type, etc.). + /// Never parenthesizes the value. + Standalone, + /// Inside a context where composites (`A | B`, `A & B`, `T | null`) must + /// be parenthesized so the surrounding operator binds correctly — both + /// composition lists (`A | B`, `A & B`) and array-item position (`X[]`). + /// Inline objects don't need wrapping in either context: `A & { x: T }` + /// parses unambiguously, and `{a: T}[]` likewise. + Wrapped, +} + +/// Render `value` to `out` at the given syntactic position. +pub(crate) fn render_type(out: &mut Writer, value: &SchemaType, position: Position) { + if needs_parens(value, position) { + out.push("("); + render_type_inner(out, value); + out.push(")"); + } else { + render_type_inner(out, value); + } +} + +/// Test-only string-returning shim around [`render_type`]. Production code +/// streams type references straight into the active `Writer`; the only +/// callers that need a `String` back are unit tests that compare rendered +/// fragments. +#[cfg(test)] +pub(crate) fn render_type_reference(value: &SchemaType) -> String { + let mut buf = Writer::with_capacity(128); + render_type(&mut buf, value, Position::Standalone); + buf.into_string() +} + +pub(crate) fn write_property_declaration( + out: &mut Writer, + name: &str, + optional: bool, + ty: &SchemaType, +) { + out.push(&safe_property_name(name)); + if optional { + out.push("?"); + } + out.push(": "); + render_type(out, ty, Position::Standalone); +} + +const fn needs_parens(value: &SchemaType, position: Position) -> bool { + let is_composite = matches!( + value, + SchemaType::Union { .. } | SchemaType::Intersection(_) | SchemaType::Nullable(_) + ); + match position { + Position::Standalone => false, + // Inline objects don't need wrapping in `A & { x: T }` — `&` binds + // lower than the property-block, and TS parses the form + // unambiguously. Composites (`|`, `&`, `T | null`) still need + // wrapping so the surrounding operator binds correctly. + Position::Wrapped => is_composite, + } +} + +fn render_type_inner(out: &mut Writer, value: &SchemaType) { + match value { + SchemaType::Any => out.push("unknown"), + SchemaType::Scalar(scalar) => out.push(scalar_keyword(scalar)), + SchemaType::Array(items) => { + render_type(out, items, Position::Wrapped); + out.push("[]"); + } + SchemaType::Map(items) => { + out.push("Record"); + } + SchemaType::StringLiterals { values } => render_string_literal_union(out, values), + SchemaType::Ref(name) => out.push(name), + SchemaType::Union { members, .. } => { + if members.is_empty() { + out.push("never"); + } else { + render_composition(out, members, " | "); + } + } + SchemaType::Intersection(members) => render_composition(out, members, " & "), + SchemaType::InlineObject { properties } => render_inline_object(out, properties), + SchemaType::Nullable(inner) => { + // Flatten `Nullable(Union { A, B })` into `A | B | null` rather + // than `(A | B) | null` — both are equivalent TS but the flat form + // mirrors OpenAPI 3.1's `oneOf: [A, B, null]` semantics. Other + // inner shapes (Intersection, InlineObject) still need parens to + // preserve precedence. + let inner_position = if matches!(inner.as_ref(), SchemaType::Union { .. }) { + Position::Standalone + } else { + Position::Wrapped + }; + render_type(out, inner, inner_position); + out.push(" | null"); + } + } +} + +const fn scalar_keyword(scalar: &SchemaScalar) -> &'static str { + match scalar { + SchemaScalar::String => "string", + SchemaScalar::Number => "number", + SchemaScalar::Boolean => "boolean", + } +} + +/// Render a form-body field type (multipart/urlencoded) as TS source. +/// +/// Binary parts surface as `Blob | File` (the runtime union the fetch +/// `FormData.append` overload accepts); scalar parts reuse the same +/// keywords as `SchemaType::Scalar`. Arrays wrap binary unions in +/// parentheses so `(Blob | File)[]` parses as an array of unions rather +/// than the precedence trap `Blob | File[]`. +pub(crate) fn render_body_field_type(ty: &BodyFieldType) -> String { + match ty { + BodyFieldType::Scalar(scalar) => scalar_keyword(scalar).to_string(), + BodyFieldType::ArrayOfScalar(scalar) => format!("{}[]", scalar_keyword(scalar)), + BodyFieldType::Binary => "Blob | File".to_string(), + BodyFieldType::ArrayOfBinary => "(Blob | File)[]".to_string(), + } +} + +fn render_composition(out: &mut Writer, members: &[SchemaType], separator: &str) { + let mut first = true; + for member in members { + if !first { + out.push(separator); + } + first = false; + render_type(out, member, Position::Wrapped); + } +} + +fn render_string_literal_union(out: &mut Writer, values: &[String]) { + let mut first = true; + for value in values { + if !first { + out.push(" | "); + } + first = false; + write_string_literal(out, value); + } +} + +fn render_inline_object(out: &mut Writer, properties: &[SchemaProperty]) { + if properties.is_empty() { + out.push("Record"); + return; + } + out.push("{\n"); + out.indent(); + for property in properties { + write_property_declaration(out, &property.name, !property.required, &property.ty); + out.push(";\n"); + } + out.dedent(); + out.push("}"); +} + +pub(crate) fn write_string_literal(out: &mut W, value: &str) { + out.write_char('\'').unwrap(); + for ch in value.chars() { + match ch { + '\\' => out.write_str("\\\\").unwrap(), + '\'' => out.write_str("\\'").unwrap(), + '\n' => out.write_str("\\n").unwrap(), + '\r' => out.write_str("\\r").unwrap(), + '\t' => out.write_str("\\t").unwrap(), + _ => out.write_char(ch).unwrap(), + } + } + out.write_char('\'').unwrap(); +} diff --git a/src/emit/typescript_tests.rs b/src/emit/typescript_tests.rs new file mode 100644 index 0000000..2469836 --- /dev/null +++ b/src/emit/typescript_tests.rs @@ -0,0 +1,460 @@ +// Tests for src/emit/typescript.rs — kept in a sibling file to keep typescript.rs +// focused on production logic. + +#[cfg(test)] +mod tests { + use super::super::typescript::*; + use crate::ir::canonical::BodyFieldType; + use crate::ir::identifier::is_valid_identifier; + use crate::ir::schema::{SchemaScalar, SchemaType}; + use crate::test_support::{nullable_property, property}; + + // ── Writer buffer ────────────────────────────────────────────────────────── + + #[test] + fn open_and_close_block_manage_indentation() { + let mut buffer = Writer::with_capacity(4096); + + buffer.open_block("export interface Pet"); + buffer.line("id: string;"); + buffer.line("name?: string;"); + buffer.close_block(""); + + assert_eq!( + buffer.into_string(), + "export interface Pet {\n id: string;\n name?: string;\n}\n" + ); + } + + #[test] + fn blank_line_only_adds_one_empty_line_between_sections() { + let mut buffer = Writer::with_capacity(4096); + + buffer.line("export type PetId = string;"); + buffer.blank_line(); + buffer.blank_line(); + buffer.line("export type PetName = string;"); + + assert_eq!( + buffer.into_string(), + "export type PetId = string;\n\nexport type PetName = string;\n" + ); + } + + // ── safe_property_name ───────────────────────────────────────────────────── + + #[test] + fn leaves_valid_identifiers_unquoted() { + for name in ["id", "Pet", "pet_id", "$ref", "_name", "petId2"] { + assert_eq!(safe_property_name(name), name); + } + } + + #[test] + fn leaves_reserved_words_unquoted_in_property_position() { + // class/default/interface/etc. are valid property names in TS. + for name in ["class", "default", "interface", "new"] { + assert_eq!(safe_property_name(name), name); + } + } + + #[test] + fn quotes_non_identifier_property_names() { + assert_eq!(safe_property_name("2legged").as_ref(), "'2legged'"); + assert_eq!(safe_property_name("kebab-case").as_ref(), "'kebab-case'"); + assert_eq!(safe_property_name("dotted.name").as_ref(), "'dotted.name'"); + assert_eq!(safe_property_name("with space").as_ref(), "'with space'"); + } + + #[test] + fn quotes_empty_name() { + assert_eq!(safe_property_name("").as_ref(), "''"); + } + + #[test] + fn escapes_embedded_quotes_and_backslashes() { + assert_eq!(safe_property_name("it's").as_ref(), "'it\\'s'"); + assert_eq!(safe_property_name("a\\b").as_ref(), "'a\\\\b'"); + } + + #[test] + fn escapes_embedded_control_chars() { + assert_eq!(safe_property_name("a\nb").as_ref(), "'a\\nb'"); + assert_eq!(safe_property_name("a\rb").as_ref(), "'a\\rb'"); + assert_eq!(safe_property_name("a\tb").as_ref(), "'a\\tb'"); + } + + // ── render_type ──────────────────────────────────────────────────────────── + + #[test] + fn render_type_reference_covers_every_type_expression_variant() { + let inline_object = SchemaType::InlineObject { + properties: vec![ + property("name", true, SchemaType::Scalar(SchemaScalar::String)), + nullable_property("nickname", false, SchemaType::Scalar(SchemaScalar::String)), + ], + }; + + let cases = vec![ + (SchemaType::Any, "unknown"), + (SchemaType::Scalar(SchemaScalar::String), "string"), + (SchemaType::Scalar(SchemaScalar::Number), "number"), + (SchemaType::Scalar(SchemaScalar::Boolean), "boolean"), + ( + SchemaType::Array(Box::new(SchemaType::Ref("Pet".into()))), + "Pet[]", + ), + ( + SchemaType::Map(Box::new(SchemaType::Scalar(SchemaScalar::Boolean))), + "Record", + ), + ( + SchemaType::StringLiterals { + values: vec!["available".to_string(), "adopted".to_string()], + }, + "'available' | 'adopted'", + ), + (SchemaType::Ref("Pet".into()), "Pet"), + ( + SchemaType::Union { + members: vec![SchemaType::Ref("Cat".into()), SchemaType::Ref("Dog".into())], + discriminator: None, + }, + "Cat | Dog", + ), + ( + SchemaType::Intersection(vec![ + SchemaType::Ref("AuditFields".into()), + SchemaType::Ref("ContactFields".into()), + ]), + "AuditFields & ContactFields", + ), + ( + inline_object, + "{\n name: string;\n nickname?: string | null;\n}", + ), + ( + SchemaType::Nullable(Box::new(SchemaType::Ref("Pet".into()))), + "Pet | null", + ), + ]; + + for (value, expected) in cases { + assert_eq!(render_type_reference(&value), expected); + } + } + + #[test] + fn render_type_reference_wraps_nested_compositions_when_required() { + let array_of_union = SchemaType::Array(Box::new(SchemaType::Union { + members: vec![SchemaType::Ref("Cat".into()), SchemaType::Ref("Dog".into())], + discriminator: None, + })); + + let intersection_with_inline = SchemaType::Intersection(vec![ + SchemaType::Ref("AuditFields".into()), + SchemaType::InlineObject { + properties: vec![property( + "nickname", + false, + SchemaType::Scalar(SchemaScalar::String), + )], + }, + ]); + + assert_eq!(render_type_reference(&array_of_union), "(Cat | Dog)[]"); + assert_eq!( + render_type_reference(&intersection_with_inline), + "AuditFields & {\n nickname?: string;\n}" + ); + } + + #[test] + fn render_type_reference_indents_nested_inline_objects() { + let nested_inline_object = SchemaType::InlineObject { + properties: vec![property( + "profile", + true, + SchemaType::InlineObject { + properties: vec![ + property( + "displayName", + true, + SchemaType::Scalar(SchemaScalar::String), + ), + property( + "metadata", + true, + SchemaType::InlineObject { + properties: vec![property( + "active", + true, + SchemaType::Scalar(SchemaScalar::Boolean), + )], + }, + ), + ], + }, + )], + }; + + assert_eq!( + render_type_reference(&nested_inline_object), + "{\n profile: {\n displayName: string;\n metadata: {\n active: boolean;\n };\n };\n}" + ); + } + + // ── render_body_field_type ───────────────────────────────────────────────── + + #[test] + fn render_body_field_type_for_each_variant() { + assert_eq!( + render_body_field_type(&BodyFieldType::Scalar(SchemaScalar::String)), + "string" + ); + assert_eq!( + render_body_field_type(&BodyFieldType::Scalar(SchemaScalar::Number)), + "number" + ); + assert_eq!( + render_body_field_type(&BodyFieldType::Scalar(SchemaScalar::Boolean)), + "boolean" + ); + assert_eq!( + render_body_field_type(&BodyFieldType::ArrayOfScalar(SchemaScalar::String)), + "string[]" + ); + assert_eq!( + render_body_field_type(&BodyFieldType::ArrayOfScalar(SchemaScalar::Number)), + "number[]" + ); + assert_eq!( + render_body_field_type(&BodyFieldType::Binary), + "Blob | File" + ); + assert_eq!( + render_body_field_type(&BodyFieldType::ArrayOfBinary), + "(Blob | File)[]" + ); + } + + // ── write_import_line wrapping ───────────────────────────────────────────── + + #[test] + fn write_import_line_emits_single_line_when_under_budget() { + let mut out = Writer::with_capacity(4096); + write_import_line(&mut out, [("Pet", None), ("PetId", None)], "./models", true); + assert_eq!( + out.into_string(), + "import type { Pet, PetId } from './models';\n" + ); + } + + #[test] + fn write_import_line_emits_alias_form() { + let mut out = Writer::with_capacity(4096); + write_import_line( + &mut out, + [("ExternalPetId", Some("PetId"))], + "@demo/types", + true, + ); + assert_eq!( + out.into_string(), + "import type { ExternalPetId as PetId } from '@demo/types';\n" + ); + } + + #[test] + fn write_import_line_wraps_to_multi_line_when_over_budget() { + // The 3 long-named imports exceed the 100-char inline budget; the + // writer should switch to one-identifier-per-line with trailing + // commas (prettier-friendly). + let mut out = Writer::with_capacity(4096); + let names: Vec<(&str, Option<&str>)> = vec![ + ("ResourceOneInterfaceWithExtraLongName", None), + ("ResourceTwoInterfaceWithExtraLongName", None), + ("ResourceThreeInterfaceWithExtraLongName", None), + ]; + write_import_line(&mut out, names, "./models", false); + assert_eq!( + out.into_string(), + concat!( + "import {\n", + " ResourceOneInterfaceWithExtraLongName,\n", + " ResourceTwoInterfaceWithExtraLongName,\n", + " ResourceThreeInterfaceWithExtraLongName,\n", + "} from './models';\n", + ), + ); + } + + #[test] + fn write_import_line_keeps_single_entry_inline_even_when_over_budget() { + // A single identifier always stays on one line — wrapping a single + // name is just noise. + let mut out = Writer::with_capacity(4096); + write_import_line( + &mut out, + [( + "ExtremelyLongIdentifierNameThatWouldOtherwiseTriggerTheWrapHeuristicYesItWould", + None, + )], + "./models", + true, + ); + let rendered = out.into_string(); + assert!(rendered.starts_with("import type { ExtremelyLongIdentifier")); + assert!(rendered.ends_with("} from './models';\n")); + // Single line means no embedded newlines other than the trailing one. + assert_eq!(rendered.matches('\n').count(), 1); + } + + // ── string_union ─────────────────────────────────────────────────────────── + + #[test] + fn string_union_escapes_embedded_quotes_and_control_chars_inline() { + let mut out = Writer::with_capacity(4096); + string_union( + &mut out, + "Tricky", + None, + false, + &["it's".to_string(), "a\\b".to_string(), "x\ny".to_string()], + ); + assert_eq!( + out.into_string(), + "export type Tricky = 'it\\'s' | 'a\\\\b' | 'x\\ny';\n" + ); + } + + #[test] + fn string_union_escapes_embedded_quotes_when_wrapped_multi_line() { + // Force the multi-line branch by exceeding ENUM_INLINE_WIDTH; each + // value renders as its own `| '...'` line and must route through the + // same escape table. + let long = "a".repeat(40); + let values = vec![ + format!("{long}-1'a"), + format!("{long}-2\\b"), + format!("{long}-3"), + ]; + let mut out = Writer::with_capacity(4096); + string_union(&mut out, "Long", None, false, &values); + let rendered = out.into_string(); + assert!(rendered.contains("| 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1\\'a'\n")); + assert!(rendered.contains("| 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-2\\\\b'\n")); + assert!(rendered.trim_end().ends_with("-3';")); + } + + #[test] + fn render_type_wraps_array_of_array_of_union() { + // Closes the (Cat | Dog)[][] precedence gap the previous 3-function + // split could leave uncovered. + let nested = SchemaType::Array(Box::new(SchemaType::Array(Box::new(SchemaType::Union { + members: vec![SchemaType::Ref("Cat".into()), SchemaType::Ref("Dog".into())], + discriminator: None, + })))); + let mut buf = Writer::with_capacity(4096); + render_type(&mut buf, &nested, Position::Standalone); + assert_eq!(buf.into_string(), "(Cat | Dog)[][]"); + } + + // ── Property-based: safe_property_name ───────────────────────────────────── + // + // The function lives in the path that converts arbitrary OpenAPI + // property names into TS-shaped output. The example tests above lock in + // representative cases; the properties here assert invariants over the + // full input space so an adversarial spec (mixed scripts, control + // characters, embedded quotes/backslashes) can't sneak in malformed + // output. + + use proptest::prelude::*; + + /// Lexes the output as either a bare identifier or a single-quoted + /// string literal. Returns true iff the lex succeeds end-to-end — + /// matches what TypeScript's parser would accept in property position. + fn is_valid_property_name_lexeme(out: &str) -> bool { + if out.is_empty() { + return false; + } + if is_valid_identifier(out) { + return true; + } + let bytes = out.as_bytes(); + if bytes[0] != b'\'' || *bytes.last().unwrap() != b'\'' || bytes.len() < 2 { + return false; + } + // Walk the interior, validating the escape table used in + // `write_string_literal` and ensuring no raw control char survives. + let interior = &out[1..out.len() - 1]; + let mut chars = interior.chars(); + while let Some(ch) = chars.next() { + match ch { + '\\' => match chars.next() { + Some('\\' | '\'' | 'n' | 'r' | 't') => {} + _ => return false, + }, + // Raw control chars (newline/CR/tab) and the closing quote are + // routed through the escape table; any literal occurrence after + // `safe_property_name` would indicate an escape miss. + '\'' | '\n' | '\r' | '\t' => return false, + _ => {} + } + } + true + } + + proptest! { + #[test] + fn safe_property_name_returns_a_lexable_property_name_for_any_input(name in ".{0,32}") { + let out = safe_property_name(&name); + prop_assert!( + is_valid_property_name_lexeme(out.as_ref()), + "safe_property_name produced unparseable output {out:?} for input {name:?}", + ); + } + + #[test] + fn safe_property_name_is_idempotent_when_input_is_a_valid_bare_ident( + first in proptest::char::range('A', 'Z'), + rest in proptest::collection::vec(prop_oneof![ + proptest::char::range('a', 'z'), + proptest::char::range('A', 'Z'), + proptest::char::range('0', '9'), + Just('_'), + ], 0..16), + ) { + let mut ident = String::new(); + ident.push(first); + ident.extend(rest); + let escaped = safe_property_name(&ident); + prop_assert_eq!(escaped.as_ref(), ident.as_str()); + } + } + + // ── jsdoc ────────────────────────────────────────────────────────────────── + + #[test] + fn jsdoc_escapes_close_comment_sequence() { + let mut out = Writer::with_capacity(4096); + jsdoc(&mut out, Some("Crafted */ injection /*"), false); + let s = out.into_string(); + // The only allowed `*/` is the trailing JSDoc closer on its own line. + // Strip exactly the opener and closer lines, then assert no `*/` remains + // in the body of the comment — i.e. the description was escaped. + let body = s + .strip_prefix("/**\n") + .and_then(|rest| rest.strip_suffix(" */\n")) + .expect("jsdoc output should be wrapped in /** ... */"); + assert!( + !body.contains("*/"), + "raw */ leaked into JSDoc body: {body}" + ); + // The replacement should keep the description readable. + assert!( + s.contains("*\\/"), + "expected escaped *\\/ in output, got: {s}" + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e165f56 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,321 @@ +use std::rc::Rc; + +use napi_derive::napi; +use serde::Serialize; + +const SEVERITY_WARNING: &str = "warning"; +const SEVERITY_ERROR: &str = "error"; + +/// Compact diagnostic taxonomy. Six codes covering every fatal/warning +/// the pipeline emits: +/// +/// * `InputInvalid` — read or decode failed (`E_INPUT_INVALID`). +/// * `UnsupportedSemantic` — accepted spec uses a shape outside the supported +/// subset (`E_UNSUPPORTED_SEMANTIC`). +/// * `InvalidReference` — `$ref` does not resolve (`E_INVALID_REFERENCE`). +/// * `InvalidOption` — caller-supplied option is invalid (`E_INVALID_OPTION`). +/// * `PolicyViolation` — IR-level rule (missing tag, missing operationId, +/// request-field collision, planner refusal) (`E_POLICY_VIOLATION`). +/// * `WriteFailed` — output file write failed (`E_WRITE_FAILED`). +/// * `Unexpected` — a panic crossed the NAPI boundary; surfaced by +/// `map_panic` so a Rust panic becomes an `E_UNEXPECTED` GenerateError +/// instead of aborting the host Node process. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiagnosticCode { + InputInvalid, + UnsupportedSemantic, + InvalidReference, + InvalidOption, + PolicyViolation, + WriteFailed, + Unexpected, +} + +impl DiagnosticCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::InputInvalid => "E_INPUT_INVALID", + Self::UnsupportedSemantic => "E_UNSUPPORTED_SEMANTIC", + Self::InvalidReference => "E_INVALID_REFERENCE", + Self::InvalidOption => "E_INVALID_OPTION", + Self::PolicyViolation => "E_POLICY_VIOLATION", + Self::WriteFailed => "E_WRITE_FAILED", + Self::Unexpected => "E_UNEXPECTED", + } + } +} + +/// Single internal diagnostic carried across the pipeline. Severity is +/// implicit (Err vs warnings-vec). `path` is an `Rc` so the reporter +/// can attach the same display path to every diagnostic by bumping a +/// refcount, not allocating a fresh `String`. +/// +/// Message convention: lead with a stage-gerund subject ("Failed to +/// decode input", "Unsupported OpenAPI semantic shape", "Failed to plan +/// services"), then state the detail, then append a sentence of +/// actionable advice when one exists ("Rename the colliding parameters +/// in the OpenAPI spec.", "Check for typos in the $ref..."). `subcode` +/// is set for `PolicyViolation` to let consumers route on a kebab-case +/// sub-class without parsing the message. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub subcode: Option<&'static str>, + pub message: String, + pub path: Rc, +} + +impl Diagnostic { + pub(crate) fn new(code: DiagnosticCode, message: impl Into, path: Rc) -> Self { + Self { + code, + subcode: None, + message: message.into(), + path, + } + } + + pub(crate) fn policy_violation( + reporter: &Reporter<'_>, + subcode: &'static str, + message: impl Into, + ) -> Self { + let mut diagnostic = reporter.error(DiagnosticCode::PolicyViolation, message); + diagnostic.subcode = Some(subcode); + diagnostic + } + + pub(crate) fn to_napi_warning(&self) -> GeneratorDiagnostic { + self.to_napi(SEVERITY_WARNING) + } + + pub(crate) fn to_napi_error(&self) -> GeneratorDiagnostic { + self.to_napi(SEVERITY_ERROR) + } + + fn to_napi(&self, severity: &'static str) -> GeneratorDiagnostic { + GeneratorDiagnostic { + code: self.code.as_str().to_string(), + subcode: self.subcode.map(str::to_string), + severity: severity.to_string(), + message: self.message.clone(), + path: self.path.as_ref().to_string(), + } + } +} + +impl std::fmt::Display for Diagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for Diagnostic {} + +/// Boundary projection of `Diagnostic` for the NAPI surface — string-typed +/// `code` is what JS consumers see and compare against. `severity` is +/// either `"warning"` or `"error"`; the TS surface narrows it to the +/// `'warning' | 'error'` union via `scripts/patch-types.mjs`. `subcode` +/// is populated only for `PolicyViolation` today; consumers route on it +/// when they need finer-grained remediation than `code` alone. +#[napi(object)] +#[derive(Clone, Debug, Serialize)] +pub struct GeneratorDiagnostic { + pub code: String, + pub subcode: Option, + pub severity: String, + pub message: String, + pub path: String, +} + +/// Borrowed breadcrumb for diagnostic context messages used during schema/operation +/// normalization. Building a `Context` value is alloc-free; only `.render()` allocates, +/// and only on the error path when a diagnostic message is actually being constructed. +/// +/// Each variant corresponds to one level of the recursive normalization walk. +/// `Copy` so inner call sites can take `context: &Context<'_>` and build a deeper +/// context by value without extra indirection. +#[derive(Clone, Copy)] +pub(crate) enum Context<'a> { + /// Top-level named schema: renders as `"schema {name}"`. + Schema(&'a str), + /// A property inside an object schema: renders as `"{parent}.{name}"`. + Property { + parent: &'a Context<'a>, + name: &'a str, + }, + /// An `additionalProperties` sub-schema: renders as `"{parent} additionalProperties"`. + AdditionalProperties { parent: &'a Context<'a> }, + /// One member of a oneOf/anyOf/allOf array (1-based index): + /// renders as `"{parent} composition member {index}"`. + CompositionMember { + parent: &'a Context<'a>, + index: usize, + }, + /// A request parameter context for an operation: renders as `"parameter {method} {path}"`. + Parameter { method: &'a str, path: &'a str }, + /// A request body context: renders as `"requestBody for {method} {path}"`. + RequestBody { method: &'a str, path: &'a str }, + /// A response schema context: renders as `"response schema for {method} {path}"`. + ResponseSchema { method: &'a str, path: &'a str }, +} + +impl<'a> Context<'a> { + /// Render the full breadcrumb chain into a `String`. This allocates — + /// call only when actually constructing a diagnostic message. + pub(crate) fn render(&self) -> String { + match self { + Context::Schema(name) => format!("schema {name}"), + Context::Property { parent, name } => format!("{}.{name}", parent.render()), + Context::AdditionalProperties { parent } => { + format!("{} additionalProperties", parent.render()) + } + Context::CompositionMember { parent, index } => { + format!("{} composition member {index}", parent.render()) + } + Context::Parameter { method, path } => format!("parameter {method} {path}"), + Context::RequestBody { method, path } => format!("requestBody for {method} {path}"), + Context::ResponseSchema { method, path } => { + format!("response schema for {method} {path}") + } + } + } +} + +/// Single reporter type carried through every pipeline stage. Holds the +/// display path (shared via `Rc` across every diagnostic it builds) +/// and a borrow into the boundary-owned warnings vec. +/// +/// Stages take `&Reporter<'_>` when they only emit fatals via +/// `.error(...)`; they take `&mut Reporter<'_>` when they also need to +/// push pre-fatal warnings via `.warning(...)`. +pub(crate) struct Reporter<'a> { + path: Rc, + warnings: &'a mut Vec, +} + +impl<'a> Reporter<'a> { + pub(crate) const fn new(path: Rc, warnings: &'a mut Vec) -> Self { + Self { path, warnings } + } + + pub(crate) fn error(&self, code: DiagnosticCode, message: impl Into) -> Diagnostic { + Diagnostic::new(code, message, Rc::clone(&self.path)) + } + + /// Push a pre-fatal warning. `subcode` is an optional stable + /// kebab-case tag that lets consumers route on a finer-grained class + /// than `code` alone; pass `None` when no such subdivision applies. + pub(crate) fn warning( + &mut self, + code: DiagnosticCode, + subcode: Option<&'static str>, + message: impl Into, + ) { + let mut diagnostic = Diagnostic::new(code, message, Rc::clone(&self.path)); + diagnostic.subcode = subcode; + self.warnings.push(diagnostic); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{Diagnostic, DiagnosticCode, Reporter}; + + #[test] + fn fatal_projects_typed_metadata_into_napi_boundary_strings() { + let diagnostic = Diagnostic::new( + DiagnosticCode::InputInvalid, + "Failed to decode input.", + std::rc::Rc::from("spec.yaml"), + ); + let napi = diagnostic.to_napi_error(); + + assert_eq!(napi.code, "E_INPUT_INVALID"); + assert_eq!(napi.severity, "error"); + + let serialized = serde_json::to_value(&napi).expect("diagnostic serializes"); + + assert_eq!( + serialized, + json!({ + "code": "E_INPUT_INVALID", + "subcode": null, + "severity": "error", + "message": "Failed to decode input.", + "path": "spec.yaml", + }) + ); + } + + #[test] + fn warning_projects_to_warning_severity_at_the_boundary() { + let diagnostic = Diagnostic::new( + DiagnosticCode::UnsupportedSemantic, + "Shape is deprecated but accepted.", + std::rc::Rc::from("spec.yaml"), + ); + let napi = diagnostic.to_napi_warning(); + + assert_eq!(napi.code, "E_UNSUPPORTED_SEMANTIC"); + assert_eq!(napi.severity, "warning"); + } + + #[test] + fn subcode_threads_through_the_napi_projection() { + let mut ctx = crate::test_support::test_ctx(); + let diagnostic = Diagnostic::policy_violation( + &ctx.reporter(), + "missing-tag", + "Failed to plan services: operation missing tag.", + ); + + assert_eq!(diagnostic.subcode, Some("missing-tag")); + let napi = diagnostic.to_napi_error(); + assert_eq!(napi.subcode.as_deref(), Some("missing-tag")); + } + + #[test] + fn warning_pushes_typed_diagnostic_carrying_path() { + let mut warnings = Vec::new(); + let mut reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml"), &mut warnings); + + reporter.warning( + DiagnosticCode::UnsupportedSemantic, + None, + "Input used a fallback path.", + ); + + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].code, DiagnosticCode::UnsupportedSemantic); + assert_eq!(warnings[0].path.as_ref(), "fixtures/spec.yaml"); + } + + #[test] + fn error_returns_a_fatal_diagnostic_without_pushing() { + let mut warnings = Vec::new(); + let reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml"), &mut warnings); + + let fatal = reporter.error(DiagnosticCode::WriteFailed, "Failed to write artifact."); + + assert_eq!(fatal.code, DiagnosticCode::WriteFailed); + assert_eq!(fatal.path.as_ref(), "fixtures/spec.yaml"); + assert!(warnings.is_empty()); + } + + #[test] + fn warnings_accumulate_in_order_on_the_caller_owned_vec() { + let mut warnings = Vec::new(); + { + let mut reporter = Reporter::new(std::rc::Rc::from("fixtures/spec.yaml"), &mut warnings); + reporter.warning(DiagnosticCode::UnsupportedSemantic, None, "First warning."); + reporter.warning(DiagnosticCode::UnsupportedSemantic, None, "Second warning."); + } + + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0].message, "First warning."); + assert_eq!(warnings[1].message, "Second warning."); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs new file mode 100644 index 0000000..376a24f --- /dev/null +++ b/src/io/mod.rs @@ -0,0 +1 @@ +pub(crate) mod writer; diff --git a/src/io/writer.rs b/src/io/writer.rs new file mode 100644 index 0000000..db6b8d0 --- /dev/null +++ b/src/io/writer.rs @@ -0,0 +1,216 @@ +use std::{fs, path::PathBuf}; + +use crate::{ + error::{Diagnostic, DiagnosticCode, Reporter}, + result::GeneratedArtifact, +}; + +/// Write a formatted line into a `Writer`. `Writer`'s `fmt::Write` impl is +/// infallible (it writes into an in-memory `String`), so the underlying +/// `writeln!` cannot fail; this macro hides the unwrap noise. +#[macro_export] +macro_rules! wln { + ($w:expr, $($arg:tt)*) => {{ + use std::fmt::Write as _; + writeln!($w, $($arg)*).expect("writing into Writer cannot fail") + }}; +} + +pub(crate) fn write_generated_artifacts( + output_path: Option<&str>, + artifacts: &[GeneratedArtifact], + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + let Some(output_path) = output_path else { + return Ok(()); + }; + + for artifact in artifacts { + write_artifact(output_path, artifact, reporter)?; + } + + Ok(()) +} + +fn write_artifact( + output_path: &str, + artifact: &GeneratedArtifact, + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + let artifact_rel = std::path::Path::new(&artifact.path); + if artifact_rel + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(reporter.error( + DiagnosticCode::WriteFailed, + format!( + "Failed to write artifact: artifact path '{}' contains parent traversal ('..').", + artifact.path + ), + )); + } + + let output_dir = PathBuf::from(output_path); + fs::create_dir_all(&output_dir).map_err(|error| { + reporter.error( + DiagnosticCode::WriteFailed, + format!("Failed to create generator output directory: {error}"), + ) + })?; + + let artifact_path = output_dir.join(&artifact.path); + if let Some(parent) = artifact_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + reporter.error( + DiagnosticCode::WriteFailed, + format!( + "Failed to create generated artifact parent directory for {}: {error}", + artifact.path + ), + ) + })?; + } + + fs::write(&artifact_path, &artifact.contents).map_err(|error| { + reporter.error( + DiagnosticCode::WriteFailed, + format!( + "Failed to write generated artifact {}: {error}", + artifact.path + ), + ) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::{result::GeneratedArtifact, test_support::test_ctx}; + + fn unique_path(label: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock works") + .as_nanos(); + std::env::temp_dir().join(format!("openapi-ng-{label}-{nanos}")) + } + + fn artifact(path: &str, contents: &str) -> GeneratedArtifact { + GeneratedArtifact { + path: path.to_string(), + contents: contents.to_string(), + } + } + + #[test] + fn write_generated_artifacts_writes_nested_artifacts_into_output_directory() { + let output_path = unique_path("artifact-writer-success"); + let mut ctx = test_ctx(); + let artifacts = vec![ + artifact("model.generated.ts", "export interface Pet {}\n"), + artifact("rest/pet.rest.generated.ts", "export class PetService {}\n"), + ]; + + super::write_generated_artifacts( + Some(output_path.to_str().expect("output path should be utf-8")), + &artifacts, + &ctx.reporter(), + ) + .expect("writer succeeds"); + + assert_eq!( + fs::read_to_string(output_path.join("model.generated.ts")) + .expect("model artifact should exist"), + "export interface Pet {}\n" + ); + assert_eq!( + fs::read_to_string(output_path.join("rest/pet.rest.generated.ts")) + .expect("service artifact should exist"), + "export class PetService {}\n" + ); + + let _ = fs::remove_dir_all(output_path); + } + + #[test] + fn write_generated_artifacts_preserves_write_output_failure_contract() { + let blocked_output_path = unique_path("artifact-writer-failure"); + fs::create_dir_all(&blocked_output_path).expect("create output directory"); + fs::write(blocked_output_path.join("rest"), "not-a-directory") + .expect("create blocking parent file"); + + let mut ctx = test_ctx(); + let failure = super::write_generated_artifacts( + Some( + blocked_output_path + .to_str() + .expect("blocked output path should be utf-8"), + ), + &[artifact( + "rest/pet.rest.generated.ts", + "export class PetService {}\n", + )], + &ctx.reporter(), + ) + .expect_err("writer should fail when parent directory cannot be created"); + + assert_eq!(failure.code, crate::error::DiagnosticCode::WriteFailed); + assert!(failure.message.contains("rest/pet.rest.generated.ts")); + + let _ = fs::remove_dir_all(blocked_output_path); + } + + #[test] + fn write_generated_artifacts_overwrites_existing_artifact() { + let output_path = unique_path("artifact-writer-overwrite"); + fs::create_dir_all(&output_path).expect("create output directory"); + fs::write(output_path.join("a.ts"), "stale content").expect("write stale file"); + + let mut ctx = test_ctx(); + let artifacts = vec![artifact("a.ts", "fresh content")]; + + super::write_generated_artifacts( + Some(output_path.to_str().expect("output path should be utf-8")), + &artifacts, + &ctx.reporter(), + ) + .expect("overwrite should succeed"); + + let content = + fs::read_to_string(output_path.join("a.ts")).expect("artifact should exist after overwrite"); + assert_eq!(content, "fresh content"); + + let _ = fs::remove_dir_all(output_path); + } + + #[test] + fn write_generated_artifacts_rejects_artifact_path_with_parent_traversal() { + let output_path = unique_path("artifact-writer-traversal"); + let mut ctx = test_ctx(); + let artifacts = vec![artifact("../escape.ts", "x")]; + + let err = super::write_generated_artifacts( + Some(output_path.to_str().expect("output path should be utf-8")), + &artifacts, + &ctx.reporter(), + ) + .expect_err("should reject artifact path containing '..'"); + + let _ = fs::remove_dir_all(output_path); + + assert_eq!(err.code, crate::error::DiagnosticCode::WriteFailed); + let msg = err.message.to_lowercase(); + assert!( + msg.contains("..") || msg.contains("parent") || msg.contains("traversal"), + "unexpected diagnostic message: {}", + err.message, + ); + } +} diff --git a/src/ir/canonical.rs b/src/ir/canonical.rs new file mode 100644 index 0000000..afade88 --- /dev/null +++ b/src/ir/canonical.rs @@ -0,0 +1,320 @@ +use crate::ir::schema::{SchemaScalar, SchemaType}; + +/// Named, top-level schema declaration. Produced by normalize and consumed +/// by the IR validator and downstream emitters. The body is a `SchemaType` +/// — interface, enum, and alias shapes all use the same carrier: +/// +/// * `SchemaType::InlineObject { properties }` → `export interface X { ... }` +/// * `SchemaType::StringLiterals { values }` → `export type X = 'a' | 'b'` +/// * any other variant → `export type X = ...` +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ModelSymbol { + pub(crate) name: Box, + pub(crate) description: Option, + /// Source schema's OpenAPI `deprecated: true`. Surfaces as + /// `@deprecated` in the JSDoc above the emitted TS declaration. + pub(crate) deprecated: bool, + pub(crate) body: SchemaType, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct RequestDef { + pub(crate) inputs: Vec, + /// `in: header` parameters. Kept separate from `inputs` so plan and + /// emit treat them as a structurally distinct group — the request + /// interface renders them as a nested `headers: { ... }` field that is + /// threaded through to `CommonRequest.headers`. + pub(crate) headers: Vec, + pub(crate) body: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RequestInputDef { + pub(crate) name: Box, + pub(crate) source: RequestInputSource, + pub(crate) required: bool, + pub(crate) ty: SchemaType, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RequestInputSource { + Path, + Query, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct HeaderDef { + pub(crate) name: Box, + pub(crate) required: bool, + pub(crate) ty: SchemaType, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RequestBodyDef { + pub(crate) required: bool, + pub(crate) content: BodyContent, +} + +/// Typed carrier for an operation's request-body content. `Json` +/// keeps the existing schema-carrying behaviour; `Multipart` and +/// `UrlEncoded` carry a flat field list (with an optional +/// `body_ref` recording the source named schema when the body was +/// declared as a top-level `$ref`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum BodyContent { + Json(SchemaType), + Multipart { + body_ref: Option>, + fields: Vec, + }, + UrlEncoded { + body_ref: Option>, + fields: Vec, + }, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct BodyField { + pub(crate) name: Box, + pub(crate) required: bool, + pub(crate) ty: BodyFieldType, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum BodyFieldType { + #[allow(dead_code)] + Scalar(SchemaScalar), + #[allow(dead_code)] + ArrayOfScalar(SchemaScalar), + #[allow(dead_code)] + Binary, + #[allow(dead_code)] + ArrayOfBinary, +} + +// TRACE is intentionally absent: Angular's HttpClient has no `.trace()` +// method, and TRACE is disabled at most production gateways for security +// reasons (XST). Specs that include it are rejected explicitly at +// normalize-time (`normalize_operation`) so the failure is visible +// rather than a silent drop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum HttpMethod { + Get, + Post, + Put, + Delete, + Patch, + Options, + Head, +} + +impl HttpMethod { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + Self::Patch => "PATCH", + Self::Options => "OPTIONS", + Self::Head => "HEAD", + } + } + + pub(crate) fn from_lowercase(value: &str) -> Option { + // NOTE: TRACE intentionally returns None here; the strict rejection + // (with its TRACE-specific remediation message) happens in + // `ir::normalize::operations::normalize_operation`, which inspects the + // raw `method` string after this returns None. + match value { + "get" => Some(Self::Get), + "post" => Some(Self::Post), + "put" => Some(Self::Put), + "delete" => Some(Self::Delete), + "patch" => Some(Self::Patch), + "options" => Some(Self::Options), + "head" => Some(Self::Head), + _ => None, + } + } +} + +impl std::fmt::Display for HttpMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OperationDef { + pub(crate) operation_id: String, + pub(crate) tags: Vec, + pub(crate) method: HttpMethod, + pub(crate) path: String, + pub(crate) request: RequestDef, + pub(crate) response: Option, + /// Non-2xx responses with a JSON schema, sorted by status ascending. + /// Populated by normalize regardless of emit config; the `errors` emit + /// target reads from here. Schemaless or non-JSON error responses are + /// silently skipped — error responses in real specs are commonly + /// underspecified, so the strict-rejection model used for success + /// responses would be hostile here. + pub(crate) errors: Vec, + /// Combined `summary` (first line) and `description` (subsequent + /// paragraph) from the OpenAPI Operation. Rendered as a JSDoc block + /// above the service operation member. + pub(crate) description: Option, + /// Source operation's OpenAPI `deprecated: true`. Surfaces as + /// `@deprecated` in the JSDoc above the service method so call sites + /// see the IDE deprecation marker. + pub(crate) deprecated: bool, +} + +/// One non-2xx response slot keyed by HTTP status. `default` and 1xx/3xx +/// are intentionally excluded — the strict typing surface the errors emit +/// builds (`OpError[400]`) only makes sense for explicit 4xx/5xx codes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ErrorResponse { + pub(crate) status: u16, + pub(crate) body: SchemaType, +} + +/// Typed carrier for an operation's success-response content. `Json` +/// keeps the existing schema-carrying behaviour (with `None` covering +/// JSON responses that declare no schema); `Blob`, `Text`, and +/// `ArrayBuffer` will be produced by the response-kind classifier in a +/// later phase so non-JSON responses can be rendered with the right +/// `HttpClient` responseType. They carry no payload because their TS +/// surface is fixed (`Blob` / `string` / `ArrayBuffer`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ResponseContent { + Json(Option), + Blob, + Text, + ArrayBuffer, +} + +#[derive(Clone, Debug)] +pub(crate) struct ApiInfo { + pub(crate) spec_version: String, + pub(crate) title: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct ApiModel { + pub(crate) info: ApiInfo, + pub(crate) schemas: Vec, + pub(crate) operations: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── HttpMethod ───────────────────────────────────────────────────────────── + // + // The rest of this module is plain data carriers — `ModelSymbol`, + // `OperationDef`, `RequestDef`, `ApiInfo`, `ApiModel`. They hold no + // logic worth testing in isolation; their behaviour is exercised by + // every normalize/plan/emit test that builds an `ApiModel`. Only + // `HttpMethod` carries a parse path (`from_lowercase`) and a render + // path (`as_str` / `Display`) that benefit from direct coverage. + + #[test] + fn http_method_round_trips_lowercase_keyword_to_uppercase_string() { + let cases = [ + ("get", HttpMethod::Get, "GET"), + ("post", HttpMethod::Post, "POST"), + ("put", HttpMethod::Put, "PUT"), + ("delete", HttpMethod::Delete, "DELETE"), + ("patch", HttpMethod::Patch, "PATCH"), + ("options", HttpMethod::Options, "OPTIONS"), + ("head", HttpMethod::Head, "HEAD"), + ]; + for (keyword, variant, rendered) in cases { + assert_eq!(HttpMethod::from_lowercase(keyword), Some(variant)); + assert_eq!(variant.as_str(), rendered); + assert_eq!(format!("{variant}"), rendered); + } + } + + #[test] + fn http_method_rejects_trace_so_normalize_can_emit_a_targeted_diagnostic() { + // TRACE returns None here — the strict rejection with its + // remediation message lives in `normalize_operation` (the comment + // on `from_lowercase` explains why this is split). + assert_eq!(HttpMethod::from_lowercase("trace"), None); + } + + #[test] + fn http_method_rejects_uppercase_and_unknown_keywords() { + // `from_lowercase` is strict about casing — the caller normalises + // the method string before invoking this. Asserting the strictness + // pins the contract so a refactor doesn't silently start accepting + // mixed-case input. + assert_eq!(HttpMethod::from_lowercase("GET"), None); + assert_eq!(HttpMethod::from_lowercase("Get"), None); + assert_eq!(HttpMethod::from_lowercase("connect"), None); + assert_eq!(HttpMethod::from_lowercase(""), None); + } + + #[test] + fn body_content_variants_have_distinct_payload_shapes() { + use crate::ir::schema::{SchemaScalar, SchemaType}; + + let json = BodyContent::Json(SchemaType::Scalar(SchemaScalar::String)); + let multipart = BodyContent::Multipart { + body_ref: None, + fields: vec![BodyField { + name: "avatar".into(), + required: true, + ty: BodyFieldType::Binary, + }], + }; + let url_encoded = BodyContent::UrlEncoded { + body_ref: Some("LoginForm".into()), + fields: vec![BodyField { + name: "username".into(), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }], + }; + + assert!(matches!(json, BodyContent::Json(_))); + assert!(matches!(multipart, BodyContent::Multipart { .. })); + assert!(matches!(url_encoded, BodyContent::UrlEncoded { .. })); + } + + #[test] + fn body_field_type_variants_cover_value_space() { + use crate::ir::schema::SchemaScalar; + + let scalar = BodyFieldType::Scalar(SchemaScalar::String); + let array_of_scalar = BodyFieldType::ArrayOfScalar(SchemaScalar::Number); + let binary = BodyFieldType::Binary; + let array_of_binary = BodyFieldType::ArrayOfBinary; + + for variant in [&scalar, &array_of_scalar, &binary, &array_of_binary] { + let _ = format!("{variant:?}"); // ensures Debug is derived + } + } + + #[test] + fn response_content_variants_carry_expected_payloads() { + use crate::ir::schema::{SchemaScalar, SchemaType}; + + let json_with_schema = ResponseContent::Json(Some(SchemaType::Scalar(SchemaScalar::String))); + let json_without = ResponseContent::Json(None); + let blob = ResponseContent::Blob; + let text = ResponseContent::Text; + let array_buffer = ResponseContent::ArrayBuffer; + + // Json variant carries an Option; others carry no payload. + assert!(matches!(json_with_schema, ResponseContent::Json(Some(_)))); + assert!(matches!(json_without, ResponseContent::Json(None))); + assert!(matches!(blob, ResponseContent::Blob)); + assert!(matches!(text, ResponseContent::Text)); + assert!(matches!(array_buffer, ResponseContent::ArrayBuffer)); + } +} diff --git a/src/ir/identifier.rs b/src/ir/identifier.rs new file mode 100644 index 0000000..2f9c986 --- /dev/null +++ b/src/ir/identifier.rs @@ -0,0 +1,18 @@ +//! Shared identifier validation used across normalize and emit. +//! +//! Normalize calls this to reject untrusted spec strings (form-field +//! names, path-template parameter names) before they reach emit and +//! land as bare JS identifiers; emit calls this when deciding whether +//! a property name needs quoting. + +/// True when `name` is a valid bare JavaScript / TypeScript identifier +/// (restricted to the ASCII subset). Matches the production grammar +/// `[A-Za-z_$][A-Za-z0-9_$]*` — digits-first, kebab-case, dotted, or +/// whitespace-bearing names all reject. +pub(crate) fn is_valid_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let first_ok = chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$'); + first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} diff --git a/src/ir/mod.rs b/src/ir/mod.rs new file mode 100644 index 0000000..e4027f3 --- /dev/null +++ b/src/ir/mod.rs @@ -0,0 +1,9 @@ +pub(crate) mod canonical; +pub(crate) mod identifier; +pub(crate) mod normalize; +pub(crate) mod schema; + +#[cfg(test)] +mod tests; + +pub(crate) use normalize::normalize_api_model; diff --git a/src/ir/normalize/mod.rs b/src/ir/normalize/mod.rs new file mode 100644 index 0000000..4c7d967 --- /dev/null +++ b/src/ir/normalize/mod.rs @@ -0,0 +1,107 @@ +mod operations; +pub(crate) mod schema; +mod semantic; +#[cfg(test)] +mod tests; + +use std::collections::BTreeMap; + +use crate::error::{Context, Diagnostic, DiagnosticCode, Reporter}; +use crate::ir::canonical::{ApiInfo, ApiModel}; +use crate::ir::schema::SchemaType; +use crate::options::ResponseTypeMapping; +use crate::parse::openapi_model::{OpenApiDocument, Schema}; +use operations::normalize_operations; +use schema::normalize_schemas; + +/// Hard cap on `Schema` recursion during normalize. Realistic OpenAPI +/// specs nest a handful of levels (the deepest committed fixture is +/// 5 layers of allOf); a value of 32 leaves a healthy margin above that +/// while still rejecting pathological / cyclic specs before they +/// overflow the thread stack. The cap sits below the serde YAML/JSON +/// recursion limit (~60), so any spec that reaches this guard already +/// represents an unsupported shape rather than a parser-rejected one. +/// +/// Threaded as a `u16` argument through the recursive callers in +/// `schema.rs` — operations.rs starts each schema walk at depth 0. +pub(crate) const MAX_NORMALIZE_DEPTH: u16 = 32; + +pub(crate) fn normalize_api_model( + document: &OpenApiDocument, + response_type_mapping: &[ResponseTypeMapping], + reporter: &mut Reporter<'_>, +) -> Result { + let schemas = normalize_schemas(&document.components.schemas, reporter)?; + let schema_index: BTreeMap<&str, &SchemaType> = + schemas.iter().map(|m| (m.name.as_ref(), &m.body)).collect(); + let operations = normalize_operations( + &document.paths, + &schema_index, + response_type_mapping, + reporter, + )?; + + let mut model = ApiModel { + info: ApiInfo { + spec_version: document.openapi.clone(), + title: document.info.title.clone(), + }, + schemas, + operations, + }; + + // Final semantic step: sort schemas, narrow discriminator member + // properties for TS emit, and validate `$ref` resolution. + semantic::finalize(&mut model, reporter)?; + + Ok(model) +} + +pub(crate) fn unsupported( + detail: impl AsRef, + reporter: &Reporter<'_>, + include_readme: bool, +) -> Diagnostic { + let suffix = if include_readme { + ". See the supported subset documented in README.md ('Out of Scope' section)." + } else { + "" + }; + reporter.error( + DiagnosticCode::UnsupportedSemantic, + format!( + "Unsupported OpenAPI semantic shape: {}{}", + detail.as_ref(), + suffix + ), + ) +} + +pub(crate) fn check_unsupported_not( + schema: &Schema, + context: &Context<'_>, + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + if schema.not.is_some() { + return Err(unsupported( + format!( + "{} uses not, which is outside the supported subset.", + context.render() + ), + reporter, + true, + )); + } + Ok(()) +} + +/// Test helper: deserialize a raw JSON value into an OpenApiDocument and normalize it. +#[cfg(test)] +pub(crate) fn normalize_document( + document: &serde_json::Value, + reporter: &mut Reporter<'_>, +) -> Result { + let doc: OpenApiDocument = serde_json::from_value(document.clone()) + .expect("test document must be a valid OpenApiDocument"); + normalize_api_model(&doc, &[], reporter) +} diff --git a/src/ir/normalize/operations.rs b/src/ir/normalize/operations.rs new file mode 100644 index 0000000..d210047 --- /dev/null +++ b/src/ir/normalize/operations.rs @@ -0,0 +1,1661 @@ +use std::collections::BTreeMap; + +use crate::error::{Context, Diagnostic, DiagnosticCode, Reporter}; +use crate::ir::canonical::{ + BodyContent, BodyField, BodyFieldType, ErrorResponse, HeaderDef, HttpMethod, OperationDef, + RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, ResponseContent, +}; +use crate::ir::identifier::is_valid_identifier; +use crate::ir::schema::{SchemaScalar, SchemaType}; +use crate::options::{ResponseType, ResponseTypeMapping}; +use crate::parse::openapi_model::{ + AdditionalProperties, MediaType, Operation, PathItem, RequestBody, Response, Schema, +}; + +use super::schema::normalize_schema; +use super::unsupported; + +pub(super) fn normalize_operations( + paths: &BTreeMap, + schema_index: &BTreeMap<&str, &SchemaType>, + response_type_mapping: &[ResponseTypeMapping], + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + let mut operations = Vec::new(); + + for (path, path_item) in paths { + validate_path_template(path, reporter)?; + for (method, operation) in path_item.operations() { + operations.push(normalize_operation( + path, + method, + operation, + schema_index, + response_type_mapping, + reporter, + )?); + } + } + + Ok(operations) +} + +/// Reject path strings with unbalanced `{` / `}` braces before emit +/// silently produces a broken TypeScript template. The path-template +/// expander in `emit/angular/request.rs` bails on a stray `{` and emits +/// the remainder verbatim — fine on validated input, but a malformed +/// spec like `/pets/{id` would yield `url: \`/pets/id\`` with no +/// `encodeURIComponent` call. Surfacing the error at normalize time +/// keeps the emit stage operating on validated IR. +fn validate_path_template(path: &str, reporter: &mut Reporter<'_>) -> Result<(), Diagnostic> { + let mut rest = path; + while let Some(open) = rest.find('{') { + let after_open = &rest[open + 1..]; + if let Some(stray) = after_open.find('{') { + let close = after_open.find('}'); + if close.is_none_or(|c| stray < c) { + return Err(unsupported( + format!( + "path template {path} contains nested '{{' which is not a valid OpenAPI parameter placeholder." + ), + reporter, + true, + )); + } + } + let Some(close) = after_open.find('}') else { + return Err(unsupported( + format!("path template {path} has an unbalanced '{{' with no matching '}}'."), + reporter, + true, + )); + }; + let name = &after_open[..close]; + if !is_valid_identifier(name) { + return Err(Diagnostic::policy_violation( + reporter, + "invalid-path-parameter-name", + format!( + "path template {path}: parameter name '{name}' is not a valid JavaScript identifier. Rename the parameter or split this path into a non-generated client." + ), + )); + } + rest = &after_open[close + 1..]; + } + if let Some(stray) = rest.find('}') { + let _ = stray; + return Err(unsupported( + format!("path template {path} has an unbalanced '}}' with no matching '{{'."), + reporter, + true, + )); + } + Ok(()) +} + +fn normalize_operation( + path_name: &str, + method: &str, + operation: &Operation, + schema_index: &BTreeMap<&str, &SchemaType>, + response_type_mapping: &[ResponseTypeMapping], + reporter: &mut Reporter<'_>, +) -> Result { + let http_method = HttpMethod::from_lowercase(method).ok_or_else(|| { + let detail = if method == "trace" { + // OpenAPI permits `trace:` but Angular's HttpClient has no + // `.trace()` helper and TRACE is disabled at most production + // gateways for security reasons (XST). Reject explicitly rather + // than silently emitting a service that references an unusable + // method. + format!("HTTP method TRACE for {path_name} is not supported; remove the trace operation or split it into a non-generated client.") + } else { + format!("unknown HTTP method {method} for {path_name}.") + }; + unsupported(detail, reporter, true) + })?; + + let operation_id = operation + .operation_id + .clone() + .unwrap_or_else(|| format!("{}_{}", method, path_name.replace(['/', '{', '}'], "_"))); + + let method_str = http_method.as_str(); + let request = normalize_request( + operation, + &operation_id, + method_str, + path_name, + schema_index, + reporter, + )?; + let response = normalize_success_response( + operation.responses.as_ref(), + http_method, + path_name, + response_type_mapping, + reporter, + )?; + + let errors = normalize_error_responses( + operation.responses.as_ref(), + http_method, + path_name, + reporter, + )?; + + Ok(OperationDef { + operation_id, + tags: operation.tags.clone(), + method: http_method, + path: path_name.to_string(), + request, + response, + errors, + description: operation.merged_description(), + deprecated: operation.deprecated, + }) +} + +fn normalize_request( + operation: &Operation, + operation_id: &str, + method: &str, + path: &str, + schema_index: &BTreeMap<&str, &SchemaType>, + reporter: &mut Reporter<'_>, +) -> Result { + let (inputs, headers) = + normalize_request_inputs(&operation.parameters, operation_id, method, path, reporter)?; + let body = normalize_request_body( + operation.request_body.as_ref(), + method, + path, + schema_index, + reporter, + )?; + Ok(RequestDef { + inputs, + headers, + body, + }) +} + +fn normalize_request_inputs( + parameters: &[crate::parse::openapi_model::Parameter], + operation_id: &str, + method: &str, + path: &str, + reporter: &mut Reporter<'_>, +) -> Result<(Vec, Vec), Diagnostic> { + let mut inputs = Vec::with_capacity(parameters.len()); + let mut headers = Vec::new(); + + for parameter in parameters { + let name = ¶meter.name; + // `None` routes the parameter to `headers`; `Some(source)` routes it to + // `inputs` with that source. `cookie` short-circuits with a warning, + // anything else is an error. + let source: Option = match parameter.location.as_str() { + "path" => Some(RequestInputSource::Path), + "query" => Some(RequestInputSource::Query), + "header" => None, + "cookie" => { + // Cookies are managed by the browser; surfacing them in the + // generated request contract would create an inconsistent API + // surface (the client can't actually set Cookie headers). Warn + // and drop the parameter here at normalize-time so downstream + // stages never see it. + reporter.warning( + DiagnosticCode::UnsupportedSemantic, + Some("unsupported-parameter-location"), + format!( + "operationId '{operation_id}': parameter '{name}' uses location 'cookie', which is not supported in the generated service contract and will be omitted.", + ), + ); + continue; + } + other => { + return Err(unsupported( + format!("parameter {name} for {method} {path} uses unsupported location {other}."), + reporter, + true, + )); + } + }; + + let required = parameter.required; + + if source == Some(RequestInputSource::Path) && !required { + return Err(unsupported( + format!("path parameter {name} for {method} {path} must be required."), + reporter, + true, + )); + } + + if parameter.content.is_some() { + return Err(unsupported( + format!("parameter {name} for {method} {path} must use schema, not content."), + reporter, + true, + )); + } + + let schema = parameter.schema.as_ref().ok_or_else(|| { + unsupported( + format!("parameter {name} for {method} {path} must define schema."), + reporter, + true, + ) + })?; + + // Each operation-level schema walk starts at depth 0; the recursion + // counter only spans a single schema tree, not the request/response + // grouping above it. + let param_context = Context::Parameter { method, path }; + let ty = normalize_schema(schema, ¶m_context, 0, reporter)?; + match ty { + SchemaType::InlineObject { .. } => { + return Err(unsupported( + format!( + "parameter {name} for {method} {path} uses an inline object schema, which is outside the supported subset." + ), + reporter, + true, + )); + } + SchemaType::Any => { + return Err(unsupported( + format!( + "parameter {name} for {method} {path} uses an empty schema, which is outside the supported subset." + ), + reporter, + true, + )); + } + _ => {} + } + + match source { + Some(source) => inputs.push(RequestInputDef { + name: name.as_str().into(), + source, + required, + ty, + }), + None => headers.push(HeaderDef { + name: name.as_str().into(), + required, + ty, + }), + } + } + + inputs.sort_by(|left, right| request_input_sort_key(left).cmp(&request_input_sort_key(right))); + headers.sort_by(|left, right| left.name.cmp(&right.name)); + Ok((inputs, headers)) +} + +fn normalize_request_body( + request_body: Option<&RequestBody>, + method: &str, + path: &str, + schema_index: &BTreeMap<&str, &SchemaType>, + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + let Some(body) = request_body else { + return Ok(None); + }; + + // Multi-content bodies cannot be represented by a single request + // contract: the caller would have to pick one media type at call site, + // which defeats the typed-client guarantees. Reject up-front with a + // dedicated subcode so downstream tooling can route on it. + if body.content.len() > 1 { + return Err(Diagnostic::policy_violation( + reporter, + "multi-content-body", + format!("requestBody for {method} {path} must declare exactly one content type."), + )); + } + + let Some((mime, media)) = body.content.iter().next() else { + return Ok(None); + }; + // OpenAPI permits MIME case variation (`Application/JSON`); lowercase + // before matching so the dispatch is case-insensitive while the arms + // remain canonical-form string literals. + let mime_lc = mime.to_ascii_lowercase(); + + let content = match mime_lc.as_str() { + "application/json" => { + let schema = media.schema.as_ref().ok_or_else(|| { + unsupported( + format!("requestBody for {method} {path} must define schema."), + reporter, + true, + ) + })?; + + let body_context = Context::RequestBody { method, path }; + let ty = normalize_schema(schema, &body_context, 0, reporter)?; + + if matches!(ty, SchemaType::Any) { + return Err(unsupported( + format!("requestBody for {method} {path} must define a concrete schema."), + reporter, + true, + )); + } + + BodyContent::Json(ty) + } + "multipart/form-data" => { + let (body_ref, fields) = normalize_form_body_fields( + media, + FormKind::Multipart, + method, + path, + schema_index, + reporter, + )?; + BodyContent::Multipart { body_ref, fields } + } + "application/x-www-form-urlencoded" => { + let (body_ref, fields) = normalize_form_body_fields( + media, + FormKind::UrlEncoded, + method, + path, + schema_index, + reporter, + )?; + BodyContent::UrlEncoded { body_ref, fields } + } + other => { + return Err(Diagnostic::policy_violation( + reporter, + "unsupported-body-content-type", + format!( + "requestBody for {method} {path}: unsupported content type {other:?}. Use application/json, multipart/form-data, or application/x-www-form-urlencoded." + ), + )); + } + }; + + Ok(Some(RequestBodyDef { + required: body.required, + content, + })) +} + +/// Discriminates between `multipart/form-data` and +/// `application/x-www-form-urlencoded` so the field walker can apply the +/// content-type-specific rejection rules (e.g. urlencoded forbids binary +/// payloads). +#[derive(Clone, Copy)] +enum FormKind { + Multipart, + UrlEncoded, +} + +/// Normalizes a `multipart/form-data` or `application/x-www-form-urlencoded` +/// media's schema into a flat list of `BodyField`s. Top-level `$ref`s to a +/// named object are recorded in the returned `body_ref` so plan/emit can +/// surface the schema name in the request contract. Returned fields are +/// sorted alphabetically for deterministic emit. +/// +/// Format-binary detection requires raw `Schema.format`, which the IR-side +/// `SchemaType` does not carry — so we peek the raw `Schema` directly for +/// inline bodies. For top-level `$ref` bodies the raw schema is not +/// reachable from `schema_index` (which carries normalized types only); the +/// resolved properties come from the normalized index and format detection +/// for ref-target bodies is currently a no-op. The Task 7 accept tests do +/// not exercise format-binary through a `$ref`. +fn normalize_form_body_fields( + media: &MediaType, + kind: FormKind, + method: &str, + path: &str, + schema_index: &BTreeMap<&str, &SchemaType>, + reporter: &mut Reporter<'_>, +) -> Result<(Option>, Vec), Diagnostic> { + let raw_schema = media.schema.as_ref().ok_or_else(|| { + Diagnostic::policy_violation( + reporter, + "missing-body-schema", + format!("requestBody for {method} {path} must define schema."), + ) + })?; + + // Open-schema pre-check: form bodies must enumerate every field + // statically so the field walker can emit a stable contract. Any form + // of `additionalProperties` (literal `true`, or a schema describing + // the additional values) means the body's shape is open-ended and + // cannot be represented as a fixed `FormData` / urlencoded layout. + // Reject up-front so the per-property walk below operates on a closed + // object. `additionalProperties: false` and the absent case are fine. + // Subcode is kind-aware so downstream tooling can route on the precise + // form variant without parsing the message. + if let Some(ap) = &raw_schema.additional_properties + && !matches!(ap, AdditionalProperties::Boolean(false)) + { + return Err(Diagnostic::policy_violation( + reporter, + open_schema_subcode(kind), + format!( + "requestBody for {method} {path}: {} bodies must not declare additionalProperties; every field must be enumerated.", + form_kind_label(kind), + ), + )); + } + + let body_context = Context::RequestBody { method, path }; + let normalized = normalize_schema(raw_schema, &body_context, 0, reporter)?; + + // Resolve a top-level $ref by looking up the resolved `SchemaType` in + // `schema_index`. The body_ref is recorded so plan/emit can re-surface + // the named schema in the request contract; the actual property walk + // uses the resolved InlineObject. + let (body_ref, resolved_ty): (Option>, &SchemaType) = match &normalized { + SchemaType::Ref(name) => { + let resolved = schema_index.get(name.as_ref()).ok_or_else(|| { + unsupported( + format!("requestBody for {method} {path} references unknown schema '{name}'.",), + reporter, + true, + ) + })?; + (Some(name.clone()), *resolved) + } + other => (None, other), + }; + + // The resolved body schema must be an `InlineObject`. Other shapes + // (Map, Array, Scalar, Union, ...) cannot be flattened into discrete + // form fields, so we reject with a kind-aware non-object-body subcode + // so downstream consumers can route on the precise reason and variant. + let SchemaType::InlineObject { properties } = resolved_ty else { + return Err(Diagnostic::policy_violation( + reporter, + non_object_body_subcode(kind), + format!( + "requestBody for {method} {path}: {} body schema must resolve to an object.", + form_kind_label(kind), + ), + )); + }; + + // Raw-peek table for format detection. Populated from the inline + // body's raw `Schema.properties`; empty when the body is a top-level + // `$ref` (raw schemas behind refs are not threaded through to this + // layer — see function-level doc comment). + let raw_property_lookup = collect_raw_property_formats(raw_schema); + + let mut fields: Vec = Vec::with_capacity(properties.len()); + for prop in properties { + // Emit interpolates the field name as a bare JS identifier in the + // form-body IIFE (`if (name !== undefined)`, `for (const v of name)`). + // Reject names that aren't valid identifiers at normalize time so + // emit operates on validated input. + if !is_valid_identifier(prop.name.as_ref()) { + return Err(Diagnostic::policy_violation( + reporter, + "invalid-form-field-name", + format!( + "body field '{name}' in {method} {path}: name is not a valid JavaScript identifier. Rename the field or split this body into a non-generated client.", + name = prop.name.as_ref(), + ), + )); + } + let raw_format = raw_property_lookup + .get(prop.name.as_ref()) + .copied() + .unwrap_or(RawPropertyFormat::default()); + let ty = classify_body_field_type( + &prop.ty, + raw_format, + kind, + method, + path, + prop.name.as_ref(), + reporter, + )?; + fields.push(BodyField { + name: prop.name.clone(), + required: prop.required, + ty, + }); + } + + fields.sort_by(|a, b| a.name.cmp(&b.name)); + Ok((body_ref, fields)) +} + +/// Format hints peeked from the raw `Schema` for one body property. +/// `own` is the property schema's own `format` (relevant for +/// `type: string, format: binary`). `items` is the array-item schema's +/// `format` (relevant for `type: array, items: { format: binary }`). +/// Both default to `None` when the property's raw schema is unavailable +/// (e.g. when the body is a top-level `$ref`). +#[derive(Clone, Copy, Default)] +struct RawPropertyFormat<'a> { + own: Option<&'a str>, + items: Option<&'a str>, +} + +/// Walks the raw inline body `Schema.properties` to build a lookup of +/// per-property `format` hints. Used to detect `format: binary` (and, +/// for arrays, `items.format: binary`) which the normalized +/// `SchemaType` deliberately does not carry — keeping format-binary +/// semantics confined to form-body normalize. +fn collect_raw_property_formats(raw_schema: &Schema) -> BTreeMap<&str, RawPropertyFormat<'_>> { + let mut lookup = BTreeMap::new(); + let Some(properties) = &raw_schema.properties else { + return lookup; + }; + for (name, schema) in properties { + let own = schema.format.as_deref(); + let items = schema + .items + .as_deref() + .and_then(|item_schema| item_schema.format.as_deref()); + lookup.insert(name.as_str(), RawPropertyFormat { own, items }); + } + lookup +} + +/// Classifies one form-body property into a `BodyFieldType`. Accept +/// branches cover scalar, array-of-scalar, binary, and array-of-binary. +/// Reject branches use kebab-case subcodes consumers route on. Subcodes +/// are FormKind-aware so downstream tooling can distinguish multipart +/// vs urlencoded reject paths without parsing the message: +/// `multipart-nested-object` / `urlencoded-nested-object`, +/// `multipart-composed-field` / `urlencoded-composed-field`, +/// `urlencoded-binary-field`. +fn classify_body_field_type( + ty: &SchemaType, + raw_format: RawPropertyFormat<'_>, + kind: FormKind, + method: &str, + path: &str, + field_name: &str, + reporter: &mut Reporter<'_>, +) -> Result { + match ty { + // Binary: string + format: binary. + SchemaType::Scalar(SchemaScalar::String) if raw_format.own == Some("binary") => match kind { + FormKind::Multipart => Ok(BodyFieldType::Binary), + // Urlencoded forbids binary payloads; a single `urlencoded-binary-field` + // subcode covers both scalar binary and array-of-binary so downstream + // routing can collapse the two reject arms into one branch. + FormKind::UrlEncoded => Err(Diagnostic::policy_violation( + reporter, + "urlencoded-binary-field", + format!( + "body field '{field_name}' in {method} {path}: binary fields are not supported in application/x-www-form-urlencoded." + ), + )), + }, + // Array of binary: array of (string + format: binary). Detected + // via the array-item's raw `format` hint (`raw_format.items`). + SchemaType::Array(inner) + if matches!(inner.as_ref(), SchemaType::Scalar(SchemaScalar::String)) + && raw_format.items == Some("binary") => + { + match kind { + FormKind::Multipart => Ok(BodyFieldType::ArrayOfBinary), + FormKind::UrlEncoded => Err(Diagnostic::policy_violation( + reporter, + "urlencoded-binary-field", + format!( + "body field '{field_name}' in {method} {path}: array-of-binary fields are not supported in application/x-www-form-urlencoded." + ), + )), + } + } + SchemaType::Scalar(scalar) => Ok(BodyFieldType::Scalar(scalar.clone())), + SchemaType::Array(inner) => match inner.as_ref() { + SchemaType::Scalar(scalar) => Ok(BodyFieldType::ArrayOfScalar(scalar.clone())), + // Arrays whose items are not scalar/binary cannot be flattened + // into repeated form-field entries; treat them as composed for + // routing purposes (consistent with the "composed" semantics for + // a field's payload shape). + _ => Err(Diagnostic::policy_violation( + reporter, + composed_field_subcode(kind), + format!( + "body field '{field_name}' in {method} {path}: array items must be scalar or binary." + ), + )), + }, + SchemaType::InlineObject { .. } | SchemaType::Ref(_) => Err(Diagnostic::policy_violation( + reporter, + nested_object_subcode(kind), + format!( + "body field '{field_name}' in {method} {path}: nested objects are not supported in {} bodies.", + form_kind_label(kind), + ), + )), + // Composed (oneOf/anyOf/allOf), Nullable, Map, non-string-literal + // enums, Any — all collapse into a single "composed" subcode so the + // downstream router can recognise the family without parsing the + // message. + _ => Err(Diagnostic::policy_violation( + reporter, + composed_field_subcode(kind), + format!( + "body field '{field_name}' in {method} {path}: composed schemas are not supported in {} bodies.", + form_kind_label(kind), + ), + )), + } +} + +/// Subcode for a nested-object reject, kind-aware so downstream tooling +/// can distinguish multipart vs urlencoded paths. +const fn nested_object_subcode(kind: FormKind) -> &'static str { + match kind { + FormKind::Multipart => "multipart-nested-object", + FormKind::UrlEncoded => "urlencoded-nested-object", + } +} + +/// Subcode for a composed-field reject (oneOf/anyOf/allOf, nullable, +/// non-string-literal enums, array-of-non-scalar, etc.), kind-aware. +const fn composed_field_subcode(kind: FormKind) -> &'static str { + match kind { + FormKind::Multipart => "multipart-composed-field", + FormKind::UrlEncoded => "urlencoded-composed-field", + } +} + +/// Subcode for a non-object top-level body reject (the resolved body +/// schema is a scalar, array, map, union, ...). Kind-aware so downstream +/// tooling can distinguish multipart vs urlencoded paths. +const fn non_object_body_subcode(kind: FormKind) -> &'static str { + match kind { + FormKind::Multipart => "multipart-non-object-body", + FormKind::UrlEncoded => "urlencoded-non-object-body", + } +} + +/// Subcode for an open-schema reject (top-level `additionalProperties` +/// is `true` or a schema). Kind-aware so consumers can distinguish +/// multipart vs urlencoded variants. +const fn open_schema_subcode(kind: FormKind) -> &'static str { + match kind { + FormKind::Multipart => "multipart-open-schema", + FormKind::UrlEncoded => "urlencoded-open-schema", + } +} + +/// Human-readable label used in diagnostic messages so consumers can tell +/// the kind apart without inspecting the subcode. +const fn form_kind_label(kind: FormKind) -> &'static str { + match kind { + FormKind::Multipart => "multipart", + FormKind::UrlEncoded => "urlencoded", + } +} + +fn normalize_success_response( + responses: Option<&BTreeMap>, + method: HttpMethod, + path_name: &str, + response_type_mapping: &[ResponseTypeMapping], + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + let Some(responses) = responses else { + return Ok(None); + }; + + let Some((_status, response)) = responses + .iter() + .find(|(status, _)| is_success_status(status)) + else { + return Ok(None); + }; + + let Some(content) = &response.content else { + return Ok(None); + }; + + let Some((mime, media)) = pick_response_media(content, response_type_mapping) else { + return Ok(None); + }; + + let kind = classify_response_kind(mime, response_type_mapping); + let response_context = Context::ResponseSchema { + method: method.as_str(), + path: path_name, + }; + + Ok(Some(match kind { + ResponseKind::Json => { + let schema = match &media.schema { + Some(s) => Some(normalize_schema(s, &response_context, 0, reporter)?), + None => None, + }; + ResponseContent::Json(schema) + } + ResponseKind::Blob => ResponseContent::Blob, + ResponseKind::Text => ResponseContent::Text, + ResponseKind::ArrayBuffer => ResponseContent::ArrayBuffer, + })) +} + +/// Collects non-2xx response slots with a JSON schema, sorted by status +/// ascending. Lenient by design: schemaless and non-JSON error responses +/// are silently skipped (real specs commonly underspecify errors, and a +/// hard rejection here would be hostile). The `default` key is also +/// skipped — the emitted surface (`OperationError[400]`) only carries +/// numeric status keys for now. +fn normalize_error_responses( + responses: Option<&BTreeMap>, + method: HttpMethod, + path_name: &str, + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + let Some(responses) = responses else { + return Ok(Vec::new()); + }; + + let mut errors: Vec = Vec::new(); + for (status_str, response) in responses { + let Some(status) = parse_error_status(status_str) else { + continue; + }; + let Some(content) = &response.content else { + continue; + }; + let Some(media) = content.get("application/json") else { + continue; + }; + let Some(raw_schema) = &media.schema else { + continue; + }; + let response_context = Context::ResponseSchema { + method: method.as_str(), + path: path_name, + }; + let body = normalize_schema(raw_schema, &response_context, 0, reporter)?; + errors.push(ErrorResponse { status, body }); + } + errors.sort_by_key(|e| e.status); + Ok(errors) +} + +/// Parses a response key as a 4xx or 5xx HTTP status code. Returns `None` +/// for 2xx, 1xx, 3xx, the `default` key, and malformed values. +fn parse_error_status(status: &str) -> Option { + if status.len() != 3 { + return None; + } + let leading = status.as_bytes()[0]; + if leading != b'4' && leading != b'5' { + return None; + } + status.parse::().ok() +} + +/// Picks the media entry to use for a response's typed body. Prefers +/// the first entry whose classification is **not** `Blob` (so +/// `application/json` alongside `application/octet-stream` picks the +/// JSON entry); falls back to the first `Blob` entry when no non-Blob +/// classification exists. Iteration over `BTreeMap` is alphabetical by +/// key, which is the source of determinism here. +fn pick_response_media<'a>( + content: &'a BTreeMap, + user_mapping: &[ResponseTypeMapping], +) -> Option<(&'a str, &'a MediaType)> { + let mut first_blob: Option<(&str, &MediaType)> = None; + for (mime, media) in content { + let kind = classify_response_kind(mime, user_mapping); + if kind != ResponseKind::Blob { + return Some((mime.as_str(), media)); + } + if first_blob.is_none() { + first_blob = Some((mime.as_str(), media)); + } + } + first_blob +} + +fn request_input_sort_key(value: &RequestInputDef) -> (u8, &str) { + let weight = match value.source { + RequestInputSource::Path => 0, + RequestInputSource::Query => 1, + }; + + (weight, &value.name) +} + +fn is_success_status(status: &str) -> bool { + status.starts_with('2') +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResponseKind { + Json, + Blob, + Text, + ArrayBuffer, +} + +fn classify_response_kind( + content_type: &str, + user_mapping: &[ResponseTypeMapping], +) -> ResponseKind { + let normalized = content_type.to_ascii_lowercase(); + + // 1. User mapping (exact case-insensitive match) wins. + if let Some(m) = user_mapping + .iter() + .find(|m| m.content_type.eq_ignore_ascii_case(&normalized)) + { + return match m.response_type { + ResponseType::Json => ResponseKind::Json, + ResponseType::Blob => ResponseKind::Blob, + ResponseType::Text => ResponseKind::Text, + ResponseType::ArrayBuffer => ResponseKind::ArrayBuffer, + }; + } + + // 2. Built-in defaults. + if normalized == "application/json" || normalized.ends_with("+json") { + return ResponseKind::Json; + } + if normalized.starts_with("text/") { + return ResponseKind::Text; + } + ResponseKind::Blob +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{ + ResponseKind, classify_response_kind, normalize_error_responses, normalize_request_body, + normalize_success_response, parse_error_status, pick_response_media, validate_path_template, + }; + use crate::ir::canonical::{BodyContent, BodyFieldType, HttpMethod}; + use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType}; + use crate::options::{ResponseType, ResponseTypeMapping}; + use crate::parse::openapi_model::{MediaType, RequestBody, Response, Schema}; + use crate::test_support::test_ctx; + + fn parse_request_body(yaml: &str) -> RequestBody { + serde_yml::from_str(yaml).expect("fixture parses as RequestBody") + } + + fn empty_schema_index<'a>() -> BTreeMap<&'a str, &'a SchemaType> { + BTreeMap::new() + } + + fn json_schema() -> Schema { + Schema::default_string() + } + + fn btreemap_with(key: K, value: V) -> BTreeMap { + BTreeMap::from([(key, value)]) + } + + #[test] + fn classifies_application_json_as_json() { + assert_eq!( + classify_response_kind("application/json", &[]), + ResponseKind::Json + ); + } + + #[test] + fn classifies_problem_json_as_json() { + assert_eq!( + classify_response_kind("application/problem+json", &[]), + ResponseKind::Json + ); + assert_eq!( + classify_response_kind("application/vnd.api+json", &[]), + ResponseKind::Json + ); + } + + #[test] + fn classifies_text_plain_as_text() { + assert_eq!( + classify_response_kind("text/plain", &[]), + ResponseKind::Text + ); + assert_eq!(classify_response_kind("text/csv", &[]), ResponseKind::Text); + } + + #[test] + fn classifies_application_pdf_as_blob_via_default() { + assert_eq!( + classify_response_kind("application/pdf", &[]), + ResponseKind::Blob + ); + } + + #[test] + fn classifies_octet_stream_as_blob_via_default() { + assert_eq!( + classify_response_kind("application/octet-stream", &[]), + ResponseKind::Blob + ); + } + + #[test] + fn user_mapping_overrides_default() { + let mapping = vec![ResponseTypeMapping { + content_type: "application/octet-stream".into(), + response_type: ResponseType::ArrayBuffer, + }]; + assert_eq!( + classify_response_kind("application/octet-stream", &mapping), + ResponseKind::ArrayBuffer + ); + } + + #[test] + fn user_mapping_matches_case_insensitively() { + let mapping = vec![ResponseTypeMapping { + content_type: "application/PDF".into(), + response_type: ResponseType::ArrayBuffer, + }]; + assert_eq!( + classify_response_kind("application/pdf", &mapping), + ResponseKind::ArrayBuffer + ); + } + + #[test] + fn pick_response_media_prefers_non_blob_classification() { + let mut content = BTreeMap::::new(); + content.insert( + "application/json".into(), + MediaType { + schema: Some(json_schema()), + }, + ); + content.insert( + "application/octet-stream".into(), + MediaType { schema: None }, + ); + + let (mime, _) = pick_response_media(&content, &[]).expect("at least one media"); + assert_eq!(mime, "application/json"); + } + + #[test] + fn pick_response_media_returns_first_blob_when_only_blob_kinds() { + let mut content = BTreeMap::::new(); + content.insert("application/pdf".into(), MediaType { schema: None }); + content.insert("application/zip".into(), MediaType { schema: None }); + let (mime, _) = pick_response_media(&content, &[]).expect("at least one media"); + // BTreeMap iteration order is sorted; "application/pdf" sorts before "application/zip". + assert_eq!(mime, "application/pdf"); + } + + #[test] + fn no_response_content_yields_none_response() { + // A response with no `content` block at all. + let response = Response { content: None }; + let mut ctx = test_ctx(); + let result = normalize_success_response( + Some(&btreemap_with("200".to_string(), response)), + HttpMethod::Get, + "/x", + &[], + &mut ctx.reporter(), + ) + .expect("normalize ok"); + assert!(result.is_none(), "missing response content => None"); + } + + // ── normalize_error_responses ──────────────────────────────────────────── + + /// Builds a Response with a single JSON content entry carrying the + /// given schema. Helper for the error-response tests below. + fn json_response(schema: Schema) -> Response { + Response { + content: Some(BTreeMap::from([( + "application/json".to_string(), + MediaType { + schema: Some(schema), + }, + )])), + } + } + + #[test] + fn parse_error_status_accepts_4xx_and_5xx_only() { + assert_eq!(parse_error_status("400"), Some(400)); + assert_eq!(parse_error_status("404"), Some(404)); + assert_eq!(parse_error_status("500"), Some(500)); + assert_eq!(parse_error_status("503"), Some(503)); + // 2xx, 1xx, 3xx, default key, and malformed values all reject. + assert_eq!(parse_error_status("200"), None); + assert_eq!(parse_error_status("101"), None); + assert_eq!(parse_error_status("301"), None); + assert_eq!(parse_error_status("default"), None); + assert_eq!(parse_error_status("4xx"), None); + assert_eq!(parse_error_status(""), None); + } + + #[test] + fn collects_4xx_and_5xx_responses_with_json_schemas_sorted_by_status() { + let mut responses = BTreeMap::new(); + responses.insert("200".to_string(), json_response(Schema::default_string())); + responses.insert("500".to_string(), json_response(Schema::default_string())); + responses.insert("400".to_string(), json_response(Schema::default_string())); + responses.insert("404".to_string(), json_response(Schema::default_string())); + + let mut ctx = test_ctx(); + let errors = + normalize_error_responses(Some(&responses), HttpMethod::Get, "/x", &mut ctx.reporter()) + .expect("normalize ok"); + + assert_eq!( + errors.iter().map(|e| e.status).collect::>(), + vec![400, 404, 500] + ); + } + + #[test] + fn skips_schemaless_and_non_json_error_responses() { + let mut responses = BTreeMap::new(); + responses.insert("400".to_string(), json_response(Schema::default_string())); + // 503: no content block at all — must be skipped without error. + responses.insert("503".to_string(), Response { content: None }); + // 502: content block, but JSON entry has no schema — must be skipped. + responses.insert( + "502".to_string(), + Response { + content: Some(BTreeMap::from([( + "application/json".to_string(), + MediaType { schema: None }, + )])), + }, + ); + // 504: only non-JSON content — must be skipped. + responses.insert( + "504".to_string(), + Response { + content: Some(BTreeMap::from([( + "text/plain".to_string(), + MediaType { + schema: Some(Schema::default_string()), + }, + )])), + }, + ); + + let mut ctx = test_ctx(); + let errors = + normalize_error_responses(Some(&responses), HttpMethod::Get, "/x", &mut ctx.reporter()) + .expect("normalize ok"); + + assert_eq!( + errors.iter().map(|e| e.status).collect::>(), + vec![400] + ); + } + + #[test] + fn skips_default_response_key() { + let mut responses = BTreeMap::new(); + responses.insert( + "default".to_string(), + json_response(Schema::default_string()), + ); + responses.insert("400".to_string(), json_response(Schema::default_string())); + + let mut ctx = test_ctx(); + let errors = + normalize_error_responses(Some(&responses), HttpMethod::Get, "/x", &mut ctx.reporter()) + .expect("normalize ok"); + + // Only 400 survives — `default` is intentionally not surfaced. + assert_eq!( + errors.iter().map(|e| e.status).collect::>(), + vec![400] + ); + } + + // ── Multipart body field walker (Task 7 — accept path) ──────────────────── + + #[test] + fn accepts_multipart_with_scalar_array_and_binary_fields() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + required: [status, avatar] + properties: + status: { type: string } + tagIds: { type: array, items: { type: number } } + avatar: { type: string, format: binary } + nickname: { type: string } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let result = normalize_request_body( + Some(&body), + "POST", + "/pets", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::Multipart { body_ref, fields } => { + assert_eq!(body_ref, None); + let names: Vec<&str> = fields.iter().map(|f| f.name.as_ref()).collect(); + // Sorted alphabetically. + assert_eq!(names, vec!["avatar", "nickname", "status", "tagIds"]); + let avatar = fields.iter().find(|f| f.name.as_ref() == "avatar").unwrap(); + assert_eq!(avatar.ty, BodyFieldType::Binary); + assert!(avatar.required); + let status = fields.iter().find(|f| f.name.as_ref() == "status").unwrap(); + assert!(matches!( + status.ty, + BodyFieldType::Scalar(SchemaScalar::String) + )); + assert!(status.required); + let nickname = fields + .iter() + .find(|f| f.name.as_ref() == "nickname") + .unwrap(); + assert!(!nickname.required); + let tag_ids = fields.iter().find(|f| f.name.as_ref() == "tagIds").unwrap(); + assert!(matches!( + tag_ids.ty, + BodyFieldType::ArrayOfScalar(SchemaScalar::Number) + )); + } + other => panic!("expected Multipart, got {other:?}"), + } + } + + #[test] + fn accepts_multipart_with_array_of_binary_fields() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + required: [galleries] + properties: + galleries: { type: array, items: { type: string, format: binary } } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let result = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::Multipart { fields, .. } => { + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].ty, BodyFieldType::ArrayOfBinary); + } + other => panic!("expected Multipart, got {other:?}"), + } + } + + #[test] + fn accepts_multipart_with_ref_to_named_object() { + let yaml = r#" +content: + multipart/form-data: + schema: + $ref: '#/components/schemas/UploadForm' +"#; + let body = parse_request_body(yaml); + let upload_form_body = SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "status".into(), + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + description: None, + deprecated: false, + }], + }; + let schema_index = BTreeMap::from([("UploadForm", &upload_form_body)]); + let mut ctx = test_ctx(); + let result = normalize_request_body( + Some(&body), + "POST", + "/x", + &schema_index, + &mut ctx.reporter(), + ) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::Multipart { body_ref, fields } => { + assert_eq!(body_ref.as_deref(), Some("UploadForm")); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].name.as_ref(), "status"); + } + other => panic!("expected Multipart, got {other:?}"), + } + } + + // ── Multipart body field walker (Task 8 — reject paths) ────────────────── + + #[test] + fn rejects_multipart_with_nested_object_field() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + properties: + metadata: + type: object + properties: + authorId: { type: string } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("nested object should fail"); + assert_eq!(err.subcode, Some("multipart-nested-object")); + } + + #[test] + fn rejects_multipart_with_composed_field() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + properties: + variant: + oneOf: + - { type: string } + - { type: number } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("composed field should fail"); + assert_eq!(err.subcode, Some("multipart-composed-field")); + } + + #[test] + fn rejects_multipart_with_additional_properties_true() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + additionalProperties: true + properties: + status: { type: string } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("open schema should fail"); + assert_eq!(err.subcode, Some("multipart-open-schema")); + } + + #[test] + fn rejects_multipart_with_non_object_top_level_schema() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: string +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("non-object body should fail"); + assert_eq!(err.subcode, Some("multipart-non-object-body")); + } + + // ── Urlencoded + content-type dispatch (Task 9) ────────────────────────── + + #[test] + fn accepts_urlencoded_with_scalar_and_array_of_scalar_fields() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + required: [status] + properties: + status: { type: string } + tagIds: { type: array, items: { type: number } } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let result = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect("normalize ok") + .expect("body present"); + + match result.content { + BodyContent::UrlEncoded { fields, .. } => { + assert_eq!( + fields.iter().map(|f| f.name.as_ref()).collect::>(), + vec!["status", "tagIds"] + ); + } + other => panic!("expected UrlEncoded, got {other:?}"), + } + } + + #[test] + fn rejects_urlencoded_with_binary_field() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + avatar: { type: string, format: binary } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("binary in urlencoded should fail"); + assert_eq!(err.subcode, Some("urlencoded-binary-field")); + } + + #[test] + fn rejects_urlencoded_with_nested_object_field() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + metadata: + type: object + properties: + authorId: { type: string } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("nested object should fail"); + assert_eq!(err.subcode, Some("urlencoded-nested-object")); + } + + #[test] + fn rejects_urlencoded_with_composed_field() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + variant: + oneOf: + - { type: string } + - { type: number } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("composed field should fail"); + assert_eq!(err.subcode, Some("urlencoded-composed-field")); + } + + #[test] + fn rejects_urlencoded_with_non_object_top_level_schema() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: string +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("non-object urlencoded body should fail"); + assert_eq!(err.subcode, Some("urlencoded-non-object-body")); + } + + #[test] + fn rejects_urlencoded_with_additional_properties_true() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + additionalProperties: true + properties: + status: { type: string } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("open urlencoded schema should fail"); + assert_eq!(err.subcode, Some("urlencoded-open-schema")); + } + + #[test] + fn rejects_body_with_multiple_content_types() { + let yaml = r#" +content: + application/json: + schema: { type: object, properties: { x: { type: string } } } + multipart/form-data: + schema: { type: object, properties: { x: { type: string } } } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("multi-content should fail"); + assert_eq!(err.subcode, Some("multi-content-body")); + } + + #[test] + fn rejects_unsupported_body_content_type() { + let yaml = r#" +content: + application/xml: + schema: { type: object, properties: { x: { type: string } } } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("xml body should fail"); + assert_eq!(err.subcode, Some("unsupported-body-content-type")); + } + + // ── validate_path_template (Issue 1b — parameter name validation) ───────── + + #[test] + fn validate_path_template_accepts_well_formed_paths() { + let mut ctx = test_ctx(); + for path in [ + "/pets", + "/pets/{id}", + "/users/{userId}/pets/{petId}", + "/_internal/{$ref}", + ] { + validate_path_template(path, &mut ctx.reporter()) + .unwrap_or_else(|err| panic!("path {path} should validate, got: {err:?}")); + } + } + + #[test] + fn validate_path_template_rejects_invalid_identifier_parameter_name() { + let mut ctx = test_ctx(); + let err = validate_path_template("/pets/{it's}", &mut ctx.reporter()) + .expect_err("invalid identifier must reject"); + assert_eq!(err.subcode, Some("invalid-path-parameter-name")); + } + + #[test] + fn validate_path_template_rejects_digits_first_parameter_name() { + let mut ctx = test_ctx(); + let err = validate_path_template("/pets/{1foo}", &mut ctx.reporter()) + .expect_err("digits-first must reject"); + assert_eq!(err.subcode, Some("invalid-path-parameter-name")); + } + + #[test] + fn validate_path_template_rejects_kebab_case_parameter_name() { + let mut ctx = test_ctx(); + let err = validate_path_template("/pets/{pet-id}", &mut ctx.reporter()) + .expect_err("kebab-case must reject"); + assert_eq!(err.subcode, Some("invalid-path-parameter-name")); + } + + #[test] + fn validate_path_template_still_rejects_unbalanced_braces() { + let mut ctx = test_ctx(); + let err = validate_path_template("/pets/{id", &mut ctx.reporter()) + .expect_err("unbalanced { must reject"); + // unsupported() uses code, not subcode; just confirm it's an error. + assert_eq!(err.code, crate::error::DiagnosticCode::UnsupportedSemantic); + } + + #[test] + fn validate_path_template_still_rejects_stray_close_brace() { + let mut ctx = test_ctx(); + let err = + validate_path_template("/pets/id}", &mut ctx.reporter()).expect_err("stray } must reject"); + assert_eq!(err.code, crate::error::DiagnosticCode::UnsupportedSemantic); + } + + // ── Form-body field name validation (Issue 1a) ──────────────────────────── + + #[test] + fn rejects_multipart_with_invalid_field_name_kebab_case() { + let yaml = r#" +content: + multipart/form-data: + schema: + type: object + properties: + x-y: { type: string } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("kebab-case field name must reject"); + assert_eq!(err.subcode, Some("invalid-form-field-name")); + } + + #[test] + fn rejects_urlencoded_with_invalid_field_name_digits_first() { + let yaml = r#" +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + "1foo": { type: string } +"#; + let body = parse_request_body(yaml); + let mut ctx = test_ctx(); + let err = normalize_request_body( + Some(&body), + "POST", + "/x", + &empty_schema_index(), + &mut ctx.reporter(), + ) + .expect_err("digits-first field name must reject"); + assert_eq!(err.subcode, Some("invalid-form-field-name")); + } +} diff --git a/src/ir/normalize/schema.rs b/src/ir/normalize/schema.rs new file mode 100644 index 0000000..5042a5e --- /dev/null +++ b/src/ir/normalize/schema.rs @@ -0,0 +1,701 @@ +use std::collections::BTreeMap; + +use crate::error::{Context, Diagnostic, DiagnosticCode, Reporter}; +use crate::ir::canonical::ModelSymbol; +use crate::ir::schema::{Discriminator, SchemaProperty, SchemaScalar, SchemaType}; +use crate::parse::openapi_model::{AdditionalProperties, Schema}; + +use super::{MAX_NORMALIZE_DEPTH, check_unsupported_not, unsupported}; + +pub(super) fn normalize_schemas( + schemas: &BTreeMap, + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + let mut normalized = Vec::with_capacity(schemas.len()); + + for (name, schema) in schemas { + normalized.push(normalize_named_schema(name, schema, reporter)?); + } + // `normalize_named_schema` starts each top-level schema walk at depth + // 0 (see calls below); the recursive helpers carry the counter down so + // a pathological spec is rejected by MAX_NORMALIZE_DEPTH before + // overflowing the thread stack. + + // Discriminator narrowing happens in `normalize::semantic::finalize` + // (the final step of `normalize_api_model`), not here. This file is a + // pure "OpenAPI schema → canonical" stage; the discriminator patch is + // emit-driven so it runs after operation lowering. + + Ok(normalized) +} + +fn normalize_named_schema( + schema_name: &str, + schema: &Schema, + reporter: &mut Reporter<'_>, +) -> Result { + let context = Context::Schema(schema_name); + check_unsupported_not(schema, &context, reporter)?; + + if let Some(values) = &schema.enum_ { + validate_string_enum_type(schema, &context, reporter)?; + return Ok(ModelSymbol { + name: schema_name.into(), + description: schema.description.clone(), + deprecated: schema.deprecated, + body: SchemaType::StringLiterals { + values: normalize_string_enum(values, &context, reporter)?, + }, + }); + } + + if schema.type_.as_deref() == Some("object") + && schema.ref_.is_none() + && !has_supported_composition(schema) + && !is_additional_properties_constraint(schema) + && !is_any_type_schema(schema) + { + return Ok(ModelSymbol { + name: schema_name.into(), + description: schema.description.clone(), + deprecated: schema.deprecated, + body: SchemaType::InlineObject { + properties: normalize_object_properties(schema, schema_name, 0, reporter)?, + }, + }); + } + + Ok(ModelSymbol { + name: schema_name.into(), + description: schema.description.clone(), + deprecated: schema.deprecated, + body: normalize_schema(schema, &context, 0, reporter)?, + }) +} + +fn normalize_object_properties( + schema: &Schema, + schema_name: &str, + depth: u16, + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + normalize_properties(schema, &Context::Schema(schema_name), depth, reporter) +} + +pub(super) fn normalize_properties( + schema: &Schema, + context: &Context<'_>, + depth: u16, + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + check_unsupported_not(schema, context, reporter)?; + + if is_additional_properties_constraint(schema) { + return Err(unsupported( + format!( + "{} uses additionalProperties after composition, which remains outside the supported subset.", + context.render() + ), + reporter, + false, + )); + } + + let Some(properties) = &schema.properties else { + return Ok(Vec::new()); + }; + + let required: std::collections::HashSet<&str> = + schema.required.iter().map(String::as_str).collect(); + let mut normalized = Vec::with_capacity(properties.len()); + + for (name, property_schema) in properties { + let required_flag = required.contains(name.as_str()); + let prop_context = Context::Property { + parent: context, + name, + }; + let base_ty = normalize_schema_raw(property_schema, &prop_context, depth, reporter)?; + let ty = apply_nullable_flag(base_ty, property_schema.nullable.unwrap_or(false)); + normalized.push(SchemaProperty { + name: name.as_str().into(), + required: required_flag, + ty, + description: property_schema.description.clone(), + deprecated: property_schema.deprecated, + }); + } + + Ok(normalized) +} + +pub(super) fn normalize_schema( + schema: &Schema, + context: &Context<'_>, + depth: u16, + reporter: &mut Reporter<'_>, +) -> Result { + let base = normalize_schema_raw(schema, context, depth, reporter)?; + Ok(apply_nullable_flag(base, schema.nullable.unwrap_or(false))) +} + +fn normalize_schema_raw( + schema: &Schema, + context: &Context<'_>, + depth: u16, + reporter: &mut Reporter<'_>, +) -> Result { + // Single chokepoint for the recursion guard: every schema-shape branch + // below either bottoms out (scalar / ref / enum / Any) or routes back + // through one of the recursive helpers, all of which forward + // `depth + 1`. Checking here keeps the bound enforceable from one + // place rather than scattered across each recursive call. + if depth >= MAX_NORMALIZE_DEPTH { + return Err(unsupported( + format!( + "{} nesting exceeds {MAX_NORMALIZE_DEPTH} levels (likely cyclic or pathological spec).", + context.render() + ), + reporter, + false, + )); + } + + // OpenAPI `format` hints (e.g. `uuid`, `date-time`, `int32`) carry + // semantic information that the current IR does not preserve — the + // generator emits the base type without format-specific narrowing. + // Surface every occurrence as a warning so spec authors see what's + // being dropped instead of the field being silently ignored. + if let Some(format) = &schema.format { + reporter.warning( + DiagnosticCode::UnsupportedSemantic, + Some("format-dropped"), + format!( + "{} declares format '{format}', which is currently dropped — the generator emits the base type without format-specific narrowing.", + context.render() + ), + ); + } + + check_unsupported_not(schema, context, reporter)?; + + if is_additional_properties_constraint(schema) { + // Safe to unwrap-via-match: is_additional_properties_constraint is true + // only when `additional_properties` is `Some(Schema)` or `Some(Boolean(true))`. + if let Some(ap) = &schema.additional_properties { + return normalize_additional_properties(schema, ap, context, depth, reporter); + } + } + + if let Some(composition) = normalize_composition(schema, context, depth, reporter)? { + return Ok(composition); + } + + if is_any_type_schema(schema) { + return Ok(SchemaType::Any); + } + + if let Some(reference) = &schema.ref_ { + return Ok(SchemaType::Ref(normalize_reference( + reference, context, reporter, + )?)); + } + + if let Some(values) = &schema.enum_ { + validate_string_enum_type(schema, context, reporter)?; + return Ok(SchemaType::StringLiterals { + values: normalize_string_enum(values, context, reporter)?, + }); + } + + match schema.type_.as_deref() { + Some("string") => Ok(SchemaType::Scalar(SchemaScalar::String)), + Some("integer" | "number") => Ok(SchemaType::Scalar(SchemaScalar::Number)), + Some("boolean") => Ok(SchemaType::Scalar(SchemaScalar::Boolean)), + Some("array") => { + let items = schema.items.as_deref().ok_or_else(|| { + unsupported( + format!("{} array schemas must define items.", context.render()), + reporter, + true, + ) + })?; + Ok(SchemaType::Array(Box::new(normalize_schema( + items, + context, + depth + 1, + reporter, + )?))) + } + Some("object") => Ok(SchemaType::InlineObject { + properties: normalize_properties(schema, context, depth + 1, reporter)?, + }), + Some(other) => Err(unsupported( + format!("{} uses unsupported type {other}.", context.render()), + reporter, + true, + )), + None => Err(unsupported( + format!( + "{} must define a supported type, $ref, or supported composition.", + context.render() + ), + reporter, + true, + )), + } +} + +fn normalize_additional_properties( + schema: &Schema, + ap: &AdditionalProperties, + context: &Context<'_>, + depth: u16, + reporter: &mut Reporter<'_>, +) -> Result { + if has_supported_composition(schema) { + return Err(unsupported( + format!( + "{} must not combine additionalProperties with composition keywords.", + context.render() + ), + reporter, + false, + )); + } + + if schema.properties.is_some() || !schema.required.is_empty() { + return Err(unsupported( + format!( + "{} combines additionalProperties with named object properties, which remains outside the supported subset.", + context.render() + ), + reporter, + false, + )); + } + + if schema.ref_.is_some() { + return Err(unsupported( + format!( + "{} must not combine additionalProperties with $ref.", + context.render() + ), + reporter, + false, + )); + } + + if let Some(type_) = &schema.type_ + && type_ != "object" + { + return Err(unsupported( + format!( + "{} uses additionalProperties with non-object type {type_}.", + context.render() + ), + reporter, + false, + )); + } + + let ap_schema = match ap { + AdditionalProperties::Schema(s) => s.as_ref(), + AdditionalProperties::Boolean(_) => { + return Err(unsupported( + format!( + "{} must define additionalProperties as a schema object.", + context.render() + ), + reporter, + false, + )); + } + }; + + let ap_context = Context::AdditionalProperties { parent: context }; + Ok(SchemaType::Map(Box::new(normalize_schema( + ap_schema, + &ap_context, + depth + 1, + reporter, + )?))) +} + +fn normalize_composition( + schema: &Schema, + context: &Context<'_>, + depth: u16, + reporter: &mut Reporter<'_>, +) -> Result, Diagnostic> { + let composition_count = [ + schema.one_of.is_some(), + schema.any_of.is_some(), + schema.all_of.is_some(), + ] + .into_iter() + .filter(|&present| present) + .count(); + + if composition_count == 0 { + return Ok(None); + } + + if composition_count > 1 { + return Err(unsupported( + format!( + "{} must not combine multiple composition keywords.", + context.render() + ), + reporter, + true, + )); + } + + if let Some(entries) = &schema.one_of { + return normalize_composition_entries( + entries, + context, + depth, + reporter, + CompositionKind::Union, + schema.discriminator.as_ref(), + ) + .map(Some); + } + + if let Some(entries) = &schema.any_of { + return normalize_composition_entries( + entries, + context, + depth, + reporter, + CompositionKind::Union, + None, + ) + .map(Some); + } + + let Some(entries) = schema.all_of.as_deref() else { + // Defensive guard: today's invariant is that `composition_count > 0` + // with one_of/any_of None implies all_of is Some, since + // `composition_count` is the population count of exactly those three + // booleans. A future refactor that adds a fourth composition keyword + // without updating this proof would otherwise crash the host Node + // process via `unreachable!`. Surfacing a typed error keeps such a + // regression user-visible. + return Err(unsupported( + format!( + "{} internal: composition counted {composition_count} keywords but none matched.", + context.render() + ), + reporter, + false, + )); + }; + normalize_composition_entries( + entries, + context, + depth, + reporter, + CompositionKind::Intersection, + None, + ) + .map(Some) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CompositionKind { + Union, + Intersection, +} + +fn normalize_composition_entries( + entries: &[Schema], + context: &Context<'_>, + depth: u16, + reporter: &mut Reporter<'_>, + kind: CompositionKind, + discriminator: Option<&crate::parse::openapi_model::Discriminator>, +) -> Result { + if entries.is_empty() { + return Err(unsupported( + format!( + "{} composition must contain at least one member.", + context.render() + ), + reporter, + true, + )); + } + + let mut normalized = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + let member_context = Context::CompositionMember { + parent: context, + index: index + 1, + }; + normalized.push(normalize_schema( + entry, + &member_context, + depth + 1, + reporter, + )?); + } + + if normalized.len() == 1 { + return Ok(normalized.remove(0)); + } + + Ok(match kind { + CompositionKind::Union => { + let discriminator = match discriminator.filter(|d| !d.property_name.is_empty()) { + None => None, + Some(d) => Some(resolve_discriminator(d, context, reporter)?), + }; + SchemaType::Union { + members: normalized, + discriminator, + } + } + CompositionKind::Intersection => SchemaType::Intersection(normalized), + }) +} + +/// Build the IR-side `Discriminator` from the parse-stage one, resolving +/// every `mapping` value to a bare schema name. +/// +/// Per the OpenAPI spec, a `discriminator.mapping` value is either a bare +/// schema name (`Cat`) or a full `$ref` (`#/components/schemas/Cat`). A +/// value that contains a `/` is treated as ref-shaped and routed through +/// `normalize_reference`, which is the single source of truth for `$ref` +/// validation across this crate — that gives external refs +/// (`http://...`), sibling-file refs (`./other.yaml#/...`), and other +/// unsupported shapes the same `E_UNSUPPORTED_SEMANTIC` diagnostic the +/// rest of the pipeline emits, instead of silently passing the literal +/// through where it would never match a union member. +/// +/// Bare names (no `/`) are accepted as-is so the common spec idiom keeps +/// working without forcing authors to write the full ref form. +fn resolve_discriminator( + parsed: &crate::parse::openapi_model::Discriminator, + context: &Context<'_>, + reporter: &Reporter<'_>, +) -> Result { + let mut mapping = std::collections::BTreeMap::new(); + for (wire_value, schema_ref) in &parsed.mapping { + let resolved = if schema_ref.contains('/') { + normalize_reference(schema_ref, context, reporter)? + } else { + schema_ref.as_str().into() + }; + mapping.insert(wire_value.as_str().into(), resolved); + } + Ok(Discriminator { + property_name: parsed.property_name.as_str().into(), + mapping, + }) +} + +/// Wrap `base` in `SchemaType::Nullable` when the OpenAPI `nullable: true` +/// flag is set. Idempotent over already-`Nullable` types. +fn apply_nullable_flag(base: SchemaType, nullable: bool) -> SchemaType { + if !nullable || matches!(base, SchemaType::Nullable(_)) { + return base; + } + SchemaType::Nullable(Box::new(base)) +} + +const fn is_any_type_schema(schema: &Schema) -> bool { + schema.type_.is_none() + && schema.ref_.is_none() + && schema.enum_.is_none() + && !has_supported_composition(schema) + && schema.additional_properties.is_none() +} + +const fn has_supported_composition(schema: &Schema) -> bool { + schema.one_of.is_some() || schema.any_of.is_some() || schema.all_of.is_some() +} + +/// True when `additionalProperties` actually constrains emission (a schema +/// object or `Boolean(true)`). `Boolean(false)` is treated as a no-op — +/// OpenAPI semantics are "no extras beyond the declared `properties`", +/// which is structurally the same as not setting the field at all for our +/// emit purposes (TS interfaces with declared properties don't accept +/// arbitrary extras by default). +const fn is_additional_properties_constraint(schema: &Schema) -> bool { + matches!( + schema.additional_properties, + Some(AdditionalProperties::Schema(_) | AdditionalProperties::Boolean(true)) + ) +} + +fn normalize_reference( + reference: &str, + context: &Context<'_>, + reporter: &Reporter<'_>, +) -> Result, Diagnostic> { + let name = reference + .strip_prefix("#/components/schemas/") + .ok_or_else(|| { + unsupported( + format!( + "{} uses unsupported reference {reference}.", + context.render() + ), + reporter, + true, + ) + })?; + + // Reject `$ref: '#/components/schemas/'` (trailing slash, empty target + // name) before the empty `Box` flows downstream — every downstream + // consumer of a ref name (naming helpers, emit-side identifier checks) + // assumes a non-empty token, so failing here keeps the host process + // alive with a clean diagnostic instead of producing nameless output. + if name.is_empty() { + return Err(unsupported( + format!( + "{} $ref target name is empty (reference {reference}).", + context.render() + ), + reporter, + true, + )); + } + + Ok(Box::from(name)) +} + +fn normalize_string_enum( + values: &[serde_json::Value], + context: &Context<'_>, + reporter: &Reporter<'_>, +) -> Result, Diagnostic> { + let mut result = Vec::with_capacity(values.len()); + for entry in values { + let Some(s) = entry.as_str() else { + return Err(unsupported( + format!("{} enum must contain only strings.", context.render()), + reporter, + true, + )); + }; + + if s.contains('\u{0000}') { + return Err(unsupported( + format!( + "{} enum values must not contain null bytes.", + context.render() + ), + reporter, + true, + )); + } + + result.push(s.to_string()); + } + + Ok(result) +} + +fn validate_string_enum_type( + schema: &Schema, + context: &Context<'_>, + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + match schema.type_.as_deref() { + Some("string") | None => Ok(()), + Some(other) => Err(unsupported( + format!( + "{} enum is supported only for string schemas, found type {other}.", + context.render() + ), + reporter, + true, + )), + } +} + +#[cfg(test)] +mod proptests { + use std::rc::Rc; + + use proptest::prelude::*; + + use super::normalize_named_schema; + use crate::error::{DiagnosticCode, Reporter}; + use crate::parse::openapi_model::Schema; + + fn arb_schema(max_depth: u32) -> impl Strategy { + let leaf = Just(Schema::default_string()); + leaf.prop_recursive(max_depth, 32, 4, |inner| { + prop_oneof![ + inner.clone().prop_map(Schema::wrap_array), + proptest::collection::vec(inner.clone(), 0..3).prop_map(Schema::wrap_one_of), + inner.prop_map(Schema::wrap_nullable), + ] + }) + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + ..ProptestConfig::default() + })] + + #[test] + fn normalize_named_schema_never_panics(schema in arb_schema(40)) { + let mut warnings = Vec::new(); + let path: Rc = Rc::from("test"); + let mut reporter = Reporter::new(path, &mut warnings); + let result = normalize_named_schema("Root", &schema, &mut reporter); + + if let Err(diag) = result { + prop_assert!( + matches!( + diag.code, + DiagnosticCode::UnsupportedSemantic | DiagnosticCode::PolicyViolation, + ), + "unexpected diagnostic code: {:?}", diag.code, + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use super::normalize_named_schema; + use crate::error::Reporter; + use crate::parse::openapi_model::Schema; + + #[test] + fn depth_exceeded_diagnostic_includes_breadcrumb_chain() { + // Build a 40-level-deep schema by wrapping in array; MAX_NORMALIZE_DEPTH is 32. + let mut schema = Schema::default_string(); + for _ in 0..40 { + schema = Schema::wrap_array(schema); + } + + let mut warnings = Vec::new(); + let path: Rc = Rc::from("test"); + let mut reporter = Reporter::new(path, &mut warnings); + let err = normalize_named_schema("Root", &schema, &mut reporter) + .expect_err("should fail with depth exceeded"); + + assert!( + err.message.contains("32"), + "expected depth limit in message: {}", + err.message, + ); + assert!( + err.message.contains("Root"), + "expected root breadcrumb in message: {}", + err.message, + ); + } +} diff --git a/src/ir/normalize/semantic.rs b/src/ir/normalize/semantic.rs new file mode 100644 index 0000000..2a61a1c --- /dev/null +++ b/src/ir/normalize/semantic.rs @@ -0,0 +1,475 @@ +//! Final semantic step of `normalize_api_model`: sort schemas, narrow +//! discriminator member properties, and validate `$ref` resolution. +//! +//! These transforms run after schema and operation lowering. They are +//! kept in a sibling file (rather than inlined into `mod.rs`) so the +//! discriminator-narrowing / reference-validation invariants are easy +//! to find and edit independently — but they are not a separate +//! pipeline stage. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::error::{Diagnostic, DiagnosticCode, Reporter}; +use crate::ir::canonical::{ApiModel, BodyContent, ModelSymbol, ResponseContent}; +use crate::ir::schema::{SchemaProperty, SchemaScalar, SchemaType, collect_type_references}; + +/// Sort the schema list alphabetically (stable iteration order), narrow +/// discriminator member properties to single-value enums, and validate +/// every `$ref` resolves to a declared top-level schema. Mutates the +/// model in place. +pub(super) fn finalize(model: &mut ApiModel, reporter: &Reporter<'_>) -> Result<(), Diagnostic> { + model + .schemas + .sort_by(|left, right| left.name.cmp(&right.name)); + + narrow_discriminator_properties(&mut model.schemas, reporter)?; + validate_references(model, reporter) +} + +/// Pre-emit transform: for each discriminated union, patches the +/// discriminator property on every member interface to a single-value +/// string literal type. Lets the TypeScript compiler narrow the union to +/// the concrete member type. +/// +/// Before patching, validates that every member interface actually +/// declares the discriminator property. A member that omits it would +/// otherwise be patched with a synthetic single-value literal that +/// never existed on the source schema — producing TS that does not +/// narrow correctly and silently diverges from the original spec. Emit +/// `E_POLICY_VIOLATION` with subcode `missing-discriminator-property` +/// so consumers see the gap loudly. +fn narrow_discriminator_properties( + symbols: &mut [ModelSymbol], + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + // Per member: map from discriminator property name to the literal + // value to assign. Building a map lets the per-property pass below do + // an O(log K) lookup instead of scanning K (prop_name, value) pairs + // per property — the original shape was O(P · K). + let mut narrowings: BTreeMap, BTreeMap, Box>> = BTreeMap::new(); + for symbol in symbols.iter() { + if let SchemaType::Union { + members, + discriminator: Some(discriminator), + .. + } = &symbol.body + { + for member in members { + if let SchemaType::Ref(schema_name) = member { + // Honor OpenAPI `discriminator.mapping`: when an entry's + // value (pre-resolved to the bare schema name at IR-build + // time) matches this member, use the entry's key as the + // wire-value literal. Fall back to a lowercased schema name + // so unmapped specs keep their previous narrowing shape. + let literal_value: Box = discriminator + .mapping + .iter() + .find(|(_, target)| target.as_ref() == schema_name.as_ref()) + .map_or_else( + || schema_name.to_ascii_lowercase().into_boxed_str(), + |(wire_value, _)| wire_value.clone(), + ); + narrowings + .entry(schema_name.clone()) + .or_default() + .insert(discriminator.property_name.clone(), literal_value); + } + } + } + } + + if narrowings.is_empty() { + return Ok(()); + } + + // First pass: validate that each member that needs a discriminator + // narrowing actually declares the property — walking InlineObject, + // Intersection (the canonical `allOf: [Base, {kind: '…'}]` shape), + // Ref, and Nullable so allOf-composed variants don't silently skip. + // Also confirms the existing property type is string-shaped before any + // mutation happens, so an integer discriminator surfaces a loud + // diagnostic instead of being coerced into a synthetic string literal. + let by_name: BTreeMap<&str, &SchemaType> = symbols + .iter() + .map(|symbol| (symbol.name.as_ref(), &symbol.body)) + .collect(); + + for symbol in symbols.iter() { + let Some(props) = narrowings.get(&symbol.name) else { + continue; + }; + for property_name in props.keys() { + let Some(property) = find_property(&symbol.body, property_name, &by_name) else { + return Err(Diagnostic::policy_violation( + reporter, + "missing-discriminator-property", + format!( + "Failed to validate spec: oneOf member '{}' does not declare the discriminator property '{}'. Add the property to the member schema (typically as `type: string`) or remove the discriminator.", + symbol.name, property_name + ), + )); + }; + if !is_string_discriminator_shape(&property.ty) { + return Err(Diagnostic::policy_violation( + reporter, + "discriminator-property-must-be-string", + format!( + "Failed to validate spec: oneOf member '{}' declares discriminator property '{}' with a non-string type. Discriminator properties must be `type: string` (optionally with an enum); change the property type or remove the discriminator.", + symbol.name, property_name + ), + )); + } + } + } + + // Second pass: mutate. Only mutates InlineObject members directly + // (either as a symbol body, or as an inline part of an Intersection). + // Ref-shaped members inherit narrowing from the referenced symbol's + // own mutation — no double-write needed. An Intersection of only + // Refs is left alone (the referenced symbols mutate themselves if + // they're also union members). + for symbol in symbols.iter_mut() { + let Some(props) = narrowings.get(&symbol.name) else { + continue; + }; + for (property_name, literal_value) in props { + narrow_property_in_body(&mut symbol.body, property_name, literal_value.as_ref()); + } + } + + Ok(()) +} + +/// Resolve a property by name across the shapes that can carry one +/// after normalization. Used by the validation pass so a discriminator +/// property hidden behind `allOf` (Intersection) or a base-class `$ref` +/// is still found. +fn find_property<'a>( + body: &'a SchemaType, + name: &str, + by_name: &BTreeMap<&str, &'a SchemaType>, +) -> Option<&'a SchemaProperty> { + match body { + SchemaType::InlineObject { properties } => properties + .iter() + .find(|property| property.name.as_ref() == name), + SchemaType::Intersection(parts) => parts + .iter() + .find_map(|part| find_property(part, name, by_name)), + SchemaType::Ref(target) => by_name + .get(target.as_ref()) + .and_then(|inner| find_property(inner, name, by_name)), + SchemaType::Nullable(inner) => find_property(inner, name, by_name), + _ => None, + } +} + +/// Predicate for the validation pass: the existing property type must +/// already be string-shaped — bare `string`, or a `'a' | 'b'` enum. +/// Anything else (integer, nullable, ref to another schema, …) is +/// rejected as `discriminator-property-must-be-string`. +const fn is_string_discriminator_shape(ty: &SchemaType) -> bool { + matches!( + ty, + SchemaType::Scalar(SchemaScalar::String) | SchemaType::StringLiterals { .. } + ) +} + +/// Narrow the named property in `body` to a single-value string literal. +/// Recurses into Intersection so a property declared on an inline part +/// of an `allOf` is mutated in place. Returns silently when the property +/// can't be reached through inline shapes — the validation pass has +/// already confirmed it exists somewhere reachable; for a Ref-only +/// intersection that points at a non-union-member base, the type just +/// stays as its original `string` shape (TS narrowing is partial in +/// that case but the surface still compiles). +fn narrow_property_in_body(body: &mut SchemaType, name: &str, literal_value: &str) -> bool { + match body { + SchemaType::InlineObject { properties } => { + if let Some(property) = properties + .iter_mut() + .find(|property| property.name.as_ref() == name) + { + property.ty = SchemaType::StringLiterals { + values: vec![literal_value.to_owned()], + }; + return true; + } + false + } + SchemaType::Intersection(parts) => { + for part in parts { + if narrow_property_in_body(part, name, literal_value) { + return true; + } + } + false + } + SchemaType::Nullable(inner) => narrow_property_in_body(inner, name, literal_value), + _ => false, + } +} + +fn validate_references(document: &ApiModel, reporter: &Reporter<'_>) -> Result<(), Diagnostic> { + let symbol_index: BTreeSet<&str> = document + .schemas + .iter() + .map(|symbol| symbol.name.as_ref()) + .collect(); + let mut refs: BTreeSet<&str> = BTreeSet::new(); + + for symbol in &document.schemas { + collect_type_references(&symbol.body, &mut refs); + } + + for operation in &document.operations { + for input in &operation.request.inputs { + collect_type_references(&input.ty, &mut refs); + } + for header in &operation.request.headers { + collect_type_references(&header.ty, &mut refs); + } + if let Some(body) = &operation.request.body { + match &body.content { + BodyContent::Json(ty) => collect_type_references(ty, &mut refs), + // Multipart / UrlEncoded bodies are not yet produced by + // normalize; their field-type references will be collected + // when the walkers land in a later phase. + BodyContent::Multipart { .. } | BodyContent::UrlEncoded { .. } => {} + } + } + if let Some(response) = &operation.response { + match response { + ResponseContent::Json(Some(ty)) => collect_type_references(ty, &mut refs), + // `Json(None)` carries no schema, and the non-JSON variants + // have fixed TS surfaces (`Blob` / `string` / `ArrayBuffer`) + // that never reference user-declared schemas. + ResponseContent::Json(None) + | ResponseContent::Blob + | ResponseContent::Text + | ResponseContent::ArrayBuffer => {} + } + } + } + + for name in refs { + if !symbol_index.contains(name) { + return Err(reporter.error( + DiagnosticCode::InvalidReference, + format!( + "Failed to validate spec: unresolved schema reference {name}. Check for typos in the $ref and confirm that components.schemas defines a top-level entry named '{name}'." + ), + )); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::narrow_discriminator_properties; + use crate::ir::canonical::ModelSymbol; + use crate::ir::schema::{Discriminator, SchemaProperty, SchemaScalar, SchemaType}; + use crate::test_support::test_ctx; + use std::collections::BTreeMap; + + fn property(name: &str, ty: SchemaType) -> SchemaProperty { + SchemaProperty { + name: name.into(), + required: true, + ty, + description: None, + deprecated: false, + } + } + + fn symbol(name: &str, body: SchemaType) -> ModelSymbol { + ModelSymbol { + name: name.into(), + description: None, + deprecated: false, + body, + } + } + + fn pet_union(members: Vec<&str>) -> SchemaType { + SchemaType::Union { + members: members + .into_iter() + .map(|n| SchemaType::Ref(n.into())) + .collect(), + discriminator: Some(Discriminator { + property_name: "kind".into(), + mapping: BTreeMap::new(), + }), + } + } + + // ── Issue 2a: Intersection walk ───────────────────────────────────────── + + #[test] + fn narrows_discriminator_property_on_intersection_member() { + // Cat: allOf: [Animal, {kind: string, whiskers: number}] + let cat_inline = SchemaType::InlineObject { + properties: vec![ + property("kind", SchemaType::Scalar(SchemaScalar::String)), + property("whiskers", SchemaType::Scalar(SchemaScalar::Number)), + ], + }; + let mut symbols = vec![ + symbol( + "Animal", + SchemaType::InlineObject { + properties: vec![property("name", SchemaType::Scalar(SchemaScalar::String))], + }, + ), + symbol( + "Cat", + SchemaType::Intersection(vec![SchemaType::Ref("Animal".into()), cat_inline]), + ), + symbol("Pet", pet_union(vec!["Cat"])), + ]; + + let mut ctx = test_ctx(); + narrow_discriminator_properties(&mut symbols, &ctx.reporter()).expect("ok"); + + let cat = symbols.iter().find(|s| s.name.as_ref() == "Cat").unwrap(); + let SchemaType::Intersection(parts) = &cat.body else { + panic!("Cat body should remain Intersection"); + }; + // The InlineObject part should now have kind narrowed to 'cat'. + let kind_ty = parts + .iter() + .find_map(|part| match part { + SchemaType::InlineObject { properties } => properties + .iter() + .find(|p| p.name.as_ref() == "kind") + .map(|p| &p.ty), + _ => None, + }) + .expect("kind property present on inline part of Intersection"); + assert_eq!( + kind_ty, + &SchemaType::StringLiterals { + values: vec!["cat".into()] + } + ); + } + + #[test] + fn validates_discriminator_via_ref_in_intersection() { + // Cat: allOf: [Animal] where only Animal declares 'kind'. Validation + // walks into the referenced Animal and finds 'kind' there — no + // missing-property diagnostic. (Mutation is partial in this shape; + // the validation pass is the security-relevant guarantee.) + let mut symbols = vec![ + symbol( + "Animal", + SchemaType::InlineObject { + properties: vec![property("kind", SchemaType::Scalar(SchemaScalar::String))], + }, + ), + symbol( + "Cat", + SchemaType::Intersection(vec![SchemaType::Ref("Animal".into())]), + ), + symbol("Pet", pet_union(vec!["Cat"])), + ]; + let mut ctx = test_ctx(); + narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + .expect("Ref-shaped intersection should validate via the referenced base"); + } + + #[test] + fn rejects_member_missing_discriminator_property_in_intersection() { + // Cat: allOf: [Animal, {whiskers}] — neither part declares 'kind'. + let mut symbols = vec![ + symbol( + "Animal", + SchemaType::InlineObject { + properties: vec![property("name", SchemaType::Scalar(SchemaScalar::String))], + }, + ), + symbol( + "Cat", + SchemaType::Intersection(vec![ + SchemaType::Ref("Animal".into()), + SchemaType::InlineObject { + properties: vec![property( + "whiskers", + SchemaType::Scalar(SchemaScalar::Number), + )], + }, + ]), + ), + symbol("Pet", pet_union(vec!["Cat"])), + ]; + let mut ctx = test_ctx(); + let err = narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + .expect_err("missing kind anywhere must reject"); + assert_eq!(err.subcode, Some("missing-discriminator-property")); + } + + // ── Issue 2b: type-check before clobbering ────────────────────────────── + + #[test] + fn rejects_integer_discriminator_property() { + let mut symbols = vec![ + symbol( + "Cat", + SchemaType::InlineObject { + properties: vec![property("kind", SchemaType::Scalar(SchemaScalar::Number))], + }, + ), + symbol("Pet", pet_union(vec!["Cat"])), + ]; + let mut ctx = test_ctx(); + let err = narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + .expect_err("integer discriminator must reject"); + assert_eq!(err.subcode, Some("discriminator-property-must-be-string")); + } + + #[test] + fn rejects_nullable_string_discriminator_property() { + let mut symbols = vec![ + symbol( + "Cat", + SchemaType::InlineObject { + properties: vec![property( + "kind", + SchemaType::Nullable(Box::new(SchemaType::Scalar(SchemaScalar::String))), + )], + }, + ), + symbol("Pet", pet_union(vec!["Cat"])), + ]; + let mut ctx = test_ctx(); + let err = narrow_discriminator_properties(&mut symbols, &ctx.reporter()) + .expect_err("nullable string discriminator must reject"); + assert_eq!(err.subcode, Some("discriminator-property-must-be-string")); + } + + #[test] + fn accepts_string_literals_discriminator_property() { + // A spec that already constrains the discriminator to a single + // literal is fine — the mutation simply rewrites to the canonical + // single-value form. + let mut symbols = vec![ + symbol( + "Cat", + SchemaType::InlineObject { + properties: vec![property( + "kind", + SchemaType::StringLiterals { + values: vec!["cat".into()], + }, + )], + }, + ), + symbol("Pet", pet_union(vec!["Cat"])), + ]; + let mut ctx = test_ctx(); + narrow_discriminator_properties(&mut symbols, &ctx.reporter()).expect("ok"); + } +} diff --git a/src/ir/normalize/tests.rs b/src/ir/normalize/tests.rs new file mode 100644 index 0000000..181d692 --- /dev/null +++ b/src/ir/normalize/tests.rs @@ -0,0 +1,478 @@ +//! Normalize tests. Each test parses a YAML/JSON fixture, runs +//! `normalize_document`, and asserts on the resulting `ApiModel` +//! shape. The final semantic step (discriminator narrowing + `$ref` +//! validation) is exercised through `normalize_document` since it runs +//! inside `normalize_api_model`. Two-stage tests that pair normalize +//! with `render_type_reference` live in `crate::ir::tests` instead. + +use serde_json::Value; + +use crate::error::DiagnosticCode; +use crate::ir::canonical::{ + ApiInfo, ApiModel, BodyContent, HeaderDef, HttpMethod, ModelSymbol, OperationDef, RequestBodyDef, + RequestDef, RequestInputDef, RequestInputSource, ResponseContent, +}; +use crate::ir::normalize::normalize_document; +use crate::ir::normalize::semantic; +use crate::ir::schema::SchemaType; +use crate::test_support::{TestReporter, test_ctx}; + +fn parse_fixture(source: &str) -> Value { + serde_yml::from_str(source).expect("fixture parses as YAML") +} + +fn find_symbol<'a>(symbols: &'a [ModelSymbol], name: &str) -> &'a ModelSymbol { + symbols + .iter() + .find(|symbol| symbol.name.as_ref() == name) + .unwrap_or_else(|| panic!("{name} schema exists")) +} + +#[test] +fn normalize_lowers_oneof_anyof_and_collapses_single_entry_composition() { + let document = parse_fixture(include_str!( + "../../../test/fixtures/oneof-anyof-composition.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/oneof-anyof-composition.openapi.yaml"); + let normalized = normalize_document(&document, &mut sink.reporter()) + .expect("normalize succeeds for supported oneOf/anyOf fixture"); + + let pet_union = find_symbol(&normalized.schemas, "PetUnion"); + match &pet_union.body { + SchemaType::Union { members, .. } => { + assert_eq!(members.len(), 2); + assert!(matches!(&members[0], SchemaType::Ref(name) if name.as_ref() == "Cat")); + assert!(matches!(&members[1], SchemaType::Ref(name) if name.as_ref() == "Dog")); + } + other => panic!("expected union alias, got {other:?}"), + } + + let adoption_request = find_symbol(&normalized.schemas, "AdoptionRequest"); + match &adoption_request.body { + SchemaType::InlineObject { properties } => { + let contact = properties + .iter() + .find(|property| property.name.as_ref() == "contact") + .expect("contact property exists"); + assert!(matches!(contact.ty, SchemaType::Union { .. })); + } + other => panic!("expected object schema, got {other:?}"), + } + + let single_entry = parse_fixture(include_str!( + "../../../test/fixtures/single-entry-composition.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/single-entry-composition.openapi.yaml"); + let normalized_single = normalize_document(&single_entry, &mut sink.reporter()) + .expect("normalize succeeds for single-entry composition fixture"); + + let animal_view = find_symbol(&normalized_single.schemas, "AnimalView"); + assert!(matches!( + &animal_view.body, + SchemaType::Ref(name) if name.as_ref() == "AnimalBase" + )); +} + +#[test] +fn normalize_supports_inline_object_allof_members_and_preserves_additional_properties_boundary() { + let document = parse_fixture(include_str!( + "../../../test/fixtures/allof-composition.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/allof-composition.openapi.yaml"); + let normalized = normalize_document(&document, &mut sink.reporter()) + .expect("normalize succeeds for supported allOf fixture"); + + let adopter_profile = find_symbol(&normalized.schemas, "AdopterProfile"); + match &adopter_profile.body { + SchemaType::Intersection(members) => { + assert_eq!(members.len(), 3); + assert!(matches!(&members[0], SchemaType::Ref(name) if name.as_ref() == "AuditFields")); + assert!(matches!(&members[1], SchemaType::Ref(name) if name.as_ref() == "ContactFields")); + assert!(matches!(&members[2], SchemaType::InlineObject { .. })); + } + other => panic!("expected intersection alias, got {other:?}"), + } + + let unsupported = parse_fixture(include_str!( + "../../../test/fixtures/unsupported-semantic.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/unsupported-semantic.openapi.yaml"); + let error = normalize_document(&unsupported, &mut sink.reporter()) + .expect_err("unsupported fixture should fail at additionalProperties boundary"); + + assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); + assert!(error.message.contains("additionalProperties")); + assert!(!error.message.contains("allOf")); +} + +#[test] +fn normalize_supports_inline_object_model_shapes_outside_allof() { + let document = parse_fixture(include_str!( + "../../../test/fixtures/inline-model.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/inline-model.openapi.yaml"); + let normalized = normalize_document(&document, &mut sink.reporter()) + .expect("normalize succeeds for inline model fixture"); + + let pet_profile = find_symbol(&normalized.schemas, "PetProfile"); + + let properties = match &pet_profile.body { + SchemaType::InlineObject { properties } => properties, + other => panic!("expected object schema, got {other:?}"), + }; + + let details = properties + .iter() + .find(|property| property.name.as_ref() == "details") + .expect("details property exists"); + match &details.ty { + SchemaType::InlineObject { properties } => { + assert!( + properties + .iter() + .any(|property| property.name.as_ref() == "displayName") + ); + let address = properties + .iter() + .find(|property| property.name.as_ref() == "address") + .expect("address property exists"); + assert!(matches!(address.ty, SchemaType::InlineObject { .. })); + } + other => panic!("expected inline object property, got {other:?}"), + } + + let labels_by_locale = properties + .iter() + .find(|property| property.name.as_ref() == "labelsByLocale") + .expect("labelsByLocale property exists"); + match &labels_by_locale.ty { + SchemaType::Map(values) => { + assert!(matches!(values.as_ref(), SchemaType::InlineObject { .. })); + } + other => panic!("expected map with inline object values, got {other:?}"), + } + + let visits = properties + .iter() + .find(|property| property.name.as_ref() == "visits") + .expect("visits property exists"); + match &visits.ty { + SchemaType::Array(items) => { + assert!(matches!(items.as_ref(), SchemaType::InlineObject { .. })); + } + other => panic!("expected array of inline objects, got {other:?}"), + } +} + +#[test] +fn normalize_supports_typed_additional_properties_for_nested_and_named_object_maps() { + let document = parse_fixture(include_str!( + "../../../test/fixtures/additional-properties.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/additional-properties.openapi.yaml"); + let normalized = normalize_document(&document, &mut sink.reporter()) + .expect("normalize succeeds for typed additionalProperties fixture"); + + let pet_catalog = find_symbol(&normalized.schemas, "PetCatalog"); + match &pet_catalog.body { + SchemaType::InlineObject { properties } => { + let scope = properties + .iter() + .find(|property| property.name.as_ref() == "scope") + .expect("scope property exists"); + + assert!(matches!( + &scope.ty, + SchemaType::StringLiterals { values } + if values == &vec![ + "available".to_string(), + "adopted".to_string(), + "foster".to_string() + ] + )); + + let pets_by_breed = properties + .iter() + .find(|property| property.name.as_ref() == "petsByBreed") + .expect("petsByBreed property exists"); + match &pets_by_breed.ty { + SchemaType::Map(values) => match values.as_ref() { + SchemaType::Array(items) => { + assert!(matches!(items.as_ref(), SchemaType::Ref(name) if name.as_ref() == "Pet")); + } + other => panic!("expected map values to be arrays, got {other:?}"), + }, + other => panic!("expected typed object map, got {other:?}"), + } + } + other => panic!("expected object schema, got {other:?}"), + } + + let pet_metadata_by_tag = find_symbol(&normalized.schemas, "PetMetadataByTag"); + match &pet_metadata_by_tag.body { + SchemaType::Map(values) => { + assert!(matches!(values.as_ref(), SchemaType::Ref(name) if name.as_ref() == "PetMetadata")); + } + other => panic!("expected map alias, got {other:?}"), + } +} + +#[test] +fn normalize_marks_only_listed_properties_as_required() { + let document = parse_fixture( + r#" +openapi: 3.0.3 +info: + title: Required Fields + version: 1.0.0 +paths: {} +components: + schemas: + RequiredExample: + type: object + required: + - id + - name + properties: + id: + type: string + optionalNote: + type: string + name: + type: string +"#, + ); + + let mut sink = TestReporter::new("test/fixtures/required-fields.yaml"); + let normalized = normalize_document(&document, &mut sink.reporter()) + .expect("normalize succeeds for required fields fixture"); + + let schema = find_symbol(&normalized.schemas, "RequiredExample"); + + let properties = match &schema.body { + SchemaType::InlineObject { properties } => properties, + other => panic!("expected object schema, got {other:?}"), + }; + + assert_eq!( + properties + .iter() + .map(|property| (property.name.as_ref(), property.required)) + .collect::>(), + std::collections::BTreeMap::from([("id", true), ("name", true), ("optionalNote", false),]) + ); +} + +#[test] +fn normalize_rejects_non_string_enums_and_invalid_enum_values() { + let non_string_enum = parse_fixture(include_str!( + "../../../test/fixtures/invalid-enum-type.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/invalid-enum-type.openapi.yaml"); + let non_string_error = normalize_document(&non_string_enum, &mut sink.reporter()) + .expect_err("non-string enum should fail normalization"); + + assert_eq!(non_string_error.code, DiagnosticCode::UnsupportedSemantic); + assert!(non_string_error.message.contains("enum")); + assert!(non_string_error.message.contains("string")); + + let invalid_enum_value: Value = serde_json::from_str(include_str!( + "../../../test/fixtures/invalid-enum-value.openapi.json" + )) + .expect("fixture parses as JSON"); + let mut sink = TestReporter::new("test/fixtures/invalid-enum-value.openapi.json"); + let invalid_value_error = normalize_document(&invalid_enum_value, &mut sink.reporter()) + .expect_err("enum value with null byte should fail normalization"); + + assert_eq!( + invalid_value_error.code, + DiagnosticCode::UnsupportedSemantic + ); + assert!(invalid_value_error.message.contains("enum")); + assert!(invalid_value_error.message.contains("null")); +} + +#[test] +fn normalize_rejects_empty_schema_parameters_outside_model_generation_scope() { + let document = parse_fixture(include_str!( + "../../../test/fixtures/empty-parameter.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/empty-parameter.openapi.yaml"); + let error = normalize_document(&document, &mut sink.reporter()) + .expect_err("empty parameter schema should fail normalization"); + + assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); + assert!(error.message.contains("parameter")); +} + +#[test] +fn normalize_rejects_ref_with_empty_target_name() { + let document = parse_fixture( + r#" +openapi: 3.0.3 +info: + title: Empty Ref Target + version: 1.0.0 +paths: {} +components: + schemas: + Wrapper: + $ref: '#/components/schemas/' +"#, + ); + + let mut sink = TestReporter::new("test/fixtures/empty-ref-target.yaml"); + let error = normalize_document(&document, &mut sink.reporter()) + .expect_err("$ref with empty target name should fail normalization"); + + assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); + assert!(error.message.contains("empty")); + assert!(error.message.contains("$ref")); +} + +#[test] +fn normalize_rejects_trace_operations_with_specific_diagnostic() { + let document = parse_fixture(include_str!( + "../../../test/fixtures/unsupported-trace.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/unsupported-trace.openapi.yaml"); + let error = normalize_document(&document, &mut sink.reporter()) + .expect_err("trace operations should fail normalization"); + + assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); + assert!(error.message.contains("TRACE")); +} + +#[test] +fn normalize_rejects_paths_with_unbalanced_braces() { + // Mirrors the `write_path_template_into` precondition: emit assumes + // every `{` in a path has a matching `}`. Without this guard, a path + // like `/pets/{id` would yield a broken TS template (`url: + // `/pets/id`` with no `${encodeURIComponent(id)}` expansion). + let document = parse_fixture(include_str!( + "../../../test/fixtures/unbalanced-path-template.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/unbalanced-path-template.openapi.yaml"); + let error = normalize_document(&document, &mut sink.reporter()) + .expect_err("unbalanced path template should fail normalization"); + + assert_eq!(error.code, DiagnosticCode::UnsupportedSemantic); + assert!(error.message.contains("unbalanced")); + assert!(error.message.contains("/pets/{id")); +} + +// ── semantic finalize (discriminator narrowing + ref validation) ───────── + +#[test] +fn semantic_finalize_lowers_operations_with_inputs_body_and_response() { + let mut model = ApiModel { + info: ApiInfo { + spec_version: "3.0.3".to_string(), + title: "Example".to_string(), + }, + schemas: vec![ModelSymbol { + name: "Example".into(), + description: None, + deprecated: false, + body: SchemaType::InlineObject { + properties: Vec::new(), + }, + }], + operations: vec![OperationDef { + operation_id: "createExample".to_string(), + tags: vec!["Example".to_string(), "Ignored".to_string()], + method: HttpMethod::Post, + path: "/examples/{id}".to_string(), + request: RequestDef { + inputs: vec![ + RequestInputDef { + name: "id".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Scalar(crate::ir::schema::SchemaScalar::String), + }, + RequestInputDef { + name: "includeInactive".into(), + source: RequestInputSource::Query, + required: false, + ty: SchemaType::Scalar(crate::ir::schema::SchemaScalar::Boolean), + }, + ], + headers: vec![HeaderDef { + name: "xTrace".into(), + required: false, + ty: SchemaType::Scalar(crate::ir::schema::SchemaScalar::String), + }], + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Array(Box::new(SchemaType::Ref( + "Example".into(), + )))), + }), + }, + response: Some(ResponseContent::Json(Some(SchemaType::Ref( + "Example".into(), + )))), + errors: Vec::new(), + description: None, + deprecated: false, + }], + }; + + let mut ctx = test_ctx(); + semantic::finalize(&mut model, &ctx.reporter()).expect("semantic finalize succeeds"); + let operation = model.operations.first().expect("operation lowered"); + + assert_eq!(operation.operation_id, "createExample"); + assert_eq!(operation.tags, vec!["Example", "Ignored"]); + assert_eq!(operation.method, HttpMethod::Post); + assert_eq!(operation.path, "/examples/{id}"); + assert_eq!(operation.request.inputs.len(), 2); + assert!(matches!( + operation.request.inputs[0].source, + RequestInputSource::Path + )); + assert!(matches!( + operation.request.inputs[1].source, + RequestInputSource::Query + )); + assert_eq!(operation.request.headers.len(), 1); + assert_eq!(operation.request.headers[0].name.as_ref(), "xTrace"); + assert!(matches!( + operation + .request + .body + .as_ref() + .expect("body present") + .content, + BodyContent::Json(SchemaType::Array(_)) + )); + assert!(matches!( + operation.response.as_ref().expect("response present"), + ResponseContent::Json(Some(SchemaType::Ref(name))) if name.as_ref() == "Example" + )); + assert_eq!(model.schemas.len(), 1); + assert_eq!(model.schemas[0].name.as_ref(), "Example"); +} + +#[test] +fn semantic_finalize_rejects_unresolved_schema_reference() { + let mut model = ApiModel { + info: ApiInfo { + spec_version: "3.0.3".to_string(), + title: "Example".to_string(), + }, + schemas: vec![ModelSymbol { + name: "Wrapper".into(), + description: None, + deprecated: false, + body: SchemaType::Ref("Missing".into()), + }], + operations: Vec::new(), + }; + + let mut ctx = test_ctx(); + let err = semantic::finalize(&mut model, &ctx.reporter()).expect_err("unresolved ref must error"); + assert_eq!(err.code, crate::error::DiagnosticCode::InvalidReference); + assert!(err.message.contains("Missing")); +} diff --git a/src/ir/schema.rs b/src/ir/schema.rs new file mode 100644 index 0000000..b4a41de --- /dev/null +++ b/src/ir/schema.rs @@ -0,0 +1,102 @@ +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SchemaProperty { + pub(crate) name: Box, + pub(crate) required: bool, + pub(crate) ty: SchemaType, + /// Description carried over from `Schema.description` of the property's + /// schema. Emitted as a JSDoc comment above the property declaration in + /// named TypeScript interfaces. Not emitted inside inline-object types + /// (where the multi-line comment would dominate the type expression). + pub(crate) description: Option, + /// Source property schema's OpenAPI `deprecated: true`. Surfaces as + /// `@deprecated` in the JSDoc above the property declaration in named + /// interfaces — invisible inside inline-object positions (where no + /// JSDoc is emitted) to keep parity with how `description` behaves. + pub(crate) deprecated: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SchemaType { + /// OpenAPI "any" schema — a schema with no constraints (no `type`, no + /// `$ref`, no composition). Renders as TS `unknown`. + Any, + Scalar(SchemaScalar), + Array(Box), + Map(Box), + /// Literal-union vehicle (TS `'a' | 'b'`). Used both as a top-level + /// `ModelSymbol.body` (renders as `export type X = 'a' | 'b'`) and as + /// an anonymous in-place form inside compositions or for the synthetic + /// single-value narrowings produced by `narrow_discriminator_properties`. + StringLiterals { + values: Vec, + }, + Ref(Box), + /// Type composition (`oneOf`/`anyOf`). `discriminator` is `Some(info)` + /// when this comes from an OpenAPI `oneOf` with a discriminator; the + /// semantic-finalize pass (`narrow_discriminator_properties`) reads it + /// to rewrite each member's discriminator property to a single-value + /// string literal so the TypeScript compiler can narrow the union to + /// the concrete member type. + Union { + members: Vec, + discriminator: Option, + }, + Intersection(Vec), + InlineObject { + properties: Vec, + }, + /// `nullable: true` carrier. Wraps any other variant; surfaces as + /// ` | null` in TS. The single canonical representation for nullability — + /// neither `SchemaProperty` nor `Union` carry a separate `nullable` flag. + Nullable(Box), +} + +/// IR-side discriminator carrier. `property_name` is the OpenAPI +/// `discriminator.propertyName`. `mapping` is a pre-resolved +/// wire-value → bare schema-name map: OpenAPI mapping values may be a +/// full `#/components/schemas/X` ref or a bare name, but both shapes +/// are normalized to bare names at IR-build time so the semantic pass +/// can match against `SchemaType::Ref` payloads with a single +/// `mapping.iter().find(...)` lookup. Empty when the source spec omits +/// `mapping` — the fallback `schema_name.to_ascii_lowercase()` literal +/// then applies. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Discriminator { + pub(crate) property_name: Box, + pub(crate) mapping: BTreeMap, Box>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SchemaScalar { + String, + Number, + Boolean, +} + +pub(crate) fn collect_type_references<'ir>(ty: &'ir SchemaType, imports: &mut BTreeSet<&'ir str>) { + walk_refs(ty, imports); +} + +fn walk_refs<'ir>(ty: &'ir SchemaType, refs: &mut BTreeSet<&'ir str>) { + match ty { + SchemaType::Any | SchemaType::Scalar(_) | SchemaType::StringLiterals { .. } => {} + SchemaType::Array(items) | SchemaType::Map(items) | SchemaType::Nullable(items) => { + walk_refs(items, refs); + } + SchemaType::Ref(name) => { + refs.insert(name.as_ref()); + } + SchemaType::Union { members, .. } | SchemaType::Intersection(members) => { + for member in members { + walk_refs(member, refs); + } + } + SchemaType::InlineObject { properties } => { + for property in properties { + walk_refs(&property.ty, refs); + } + } + } +} diff --git a/src/ir/tests.rs b/src/ir/tests.rs new file mode 100644 index 0000000..15330c1 --- /dev/null +++ b/src/ir/tests.rs @@ -0,0 +1,216 @@ +//! Two-stage tests that drive `normalize_document` → +//! `render_type_reference`. They exercise the IR-shape invariants the +//! emit layer relies on. (Discriminator narrowing and `$ref` +//! validation run inside `normalize_api_model`, so a single +//! `normalize_document` call now returns the finalized IR.) Pure +//! normalize tests with no render step live next to the normalize +//! stage in `ir::normalize::tests`. + +use serde_json::Value; + +use crate::emit::typescript::render_type_reference; +use crate::ir::canonical::ModelSymbol; +use crate::ir::normalize::normalize_document; +use crate::ir::schema::SchemaType; +use crate::test_support::TestReporter; + +fn parse_fixture(source: &str) -> Value { + serde_yml::from_str(source).expect("fixture parses as YAML") +} + +fn find_symbol<'a>(symbols: &'a [ModelSymbol], name: &str) -> &'a ModelSymbol { + symbols + .iter() + .find(|symbol| symbol.name.as_ref() == name) + .unwrap_or_else(|| panic!("{name} schema exists")) +} + +#[test] +fn normalize_supports_empty_schema_any_type_and_empty_object_shapes() { + let document = parse_fixture(include_str!( + "../../test/fixtures/empty-shapes.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/empty-shapes.openapi.yaml"); + let ir = normalize_document(&document, &mut sink.reporter()) + .expect("normalize succeeds for empty schema fixture"); + + let any_value = find_symbol(&ir.schemas, "AnyValue"); + assert!(!matches!(&any_value.body, SchemaType::Ref(_))); + assert_eq!(render_type_reference(&any_value.body), "unknown"); + + for schema_name in ["EmptyObject", "EmptyObjectWithProperties"] { + let empty_object = find_symbol(&ir.schemas, schema_name); + match &empty_object.body { + SchemaType::InlineObject { properties } => { + assert!( + properties.is_empty(), + "{schema_name} should have no properties" + ); + } + other => panic!("expected object schema for {schema_name}, got {other:?}"), + } + } + + let shape_container = find_symbol(&ir.schemas, "ShapeContainer"); + let properties = match &shape_container.body { + SchemaType::InlineObject { properties } => properties, + other => panic!("expected object schema, got {other:?}"), + }; + + let anything = properties + .iter() + .find(|property| property.name.as_ref() == "anything") + .expect("anything property exists"); + assert_eq!(render_type_reference(&anything.ty), "unknown"); + + let empty_inline = properties + .iter() + .find(|property| property.name.as_ref() == "emptyInline") + .expect("emptyInline property exists"); + let empty_inline_with_properties = properties + .iter() + .find(|property| property.name.as_ref() == "emptyInlineWithProperties") + .expect("emptyInlineWithProperties property exists"); + let empty_array = properties + .iter() + .find(|property| property.name.as_ref() == "emptyArray") + .expect("emptyArray property exists"); + let empty_map = properties + .iter() + .find(|property| property.name.as_ref() == "emptyMap") + .expect("emptyMap property exists"); + + for property in [empty_inline, empty_inline_with_properties] { + match &property.ty { + SchemaType::InlineObject { properties } => assert!(properties.is_empty()), + other => panic!("expected empty inline object, got {other:?}"), + } + } + + match &empty_array.ty { + SchemaType::Array(items) => { + assert_eq!(render_type_reference(&empty_array.ty), "unknown[]"); + assert!(!matches!(items.as_ref(), SchemaType::Ref(_))); + } + other => panic!("expected array, got {other:?}"), + } + + match &empty_map.ty { + SchemaType::Map(values) => { + assert_eq!( + render_type_reference(&empty_map.ty), + "Record" + ); + assert!(!matches!(values.as_ref(), SchemaType::Ref(_))); + } + other => panic!("expected map, got {other:?}"), + } +} + +#[test] +fn ir_renders_union_and_intersection_type_fragments_from_normalized_composition() { + let oneof_document = parse_fixture(include_str!( + "../../test/fixtures/oneof-anyof-composition.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/oneof-anyof-composition.openapi.yaml"); + let oneof_ir = + normalize_document(&oneof_document, &mut sink.reporter()).expect("normalize succeeds"); + + let pet_union = oneof_ir + .schemas + .iter() + .find_map(|symbol| { + if symbol.name.as_ref() == "PetUnion" { + Some(&symbol.body) + } else { + None + } + }) + .expect("PetUnion alias exists in IR"); + assert_eq!(render_type_reference(pet_union), "Cat | Dog"); + + let allof_document = parse_fixture(include_str!( + "../../test/fixtures/allof-composition.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/allof-composition.openapi.yaml"); + let allof_ir = + normalize_document(&allof_document, &mut sink.reporter()).expect("normalize succeeds"); + + let adopter_profile = allof_ir + .schemas + .iter() + .find_map(|symbol| { + if symbol.name.as_ref() == "AdopterProfile" { + Some(&symbol.body) + } else { + None + } + }) + .expect("AdopterProfile alias exists in IR"); + + match adopter_profile { + SchemaType::Intersection(members) => { + assert_eq!(members.len(), 3); + let rendered = render_type_reference(adopter_profile); + assert!(rendered.contains("AuditFields & ContactFields & {")); + assert!(rendered.contains("nickname?: string | null;")); + } + other => panic!("expected IR intersection, got {other:?}"), + } + + let additional_properties_document = parse_fixture(include_str!( + "../../test/fixtures/additional-properties.openapi.yaml" + )); + let mut sink = TestReporter::new("test/fixtures/additional-properties.openapi.yaml"); + let additional_properties_ir = + normalize_document(&additional_properties_document, &mut sink.reporter()) + .expect("normalize succeeds"); + + let pet_catalog_pets_by_breed = additional_properties_ir + .schemas + .iter() + .find_map(|symbol| match &symbol.body { + SchemaType::InlineObject { properties } if symbol.name.as_ref() == "PetCatalog" => properties + .iter() + .find(|property| property.name.as_ref() == "petsByBreed") + .map(|property| &property.ty), + _ => None, + }) + .expect("PetCatalog.petsByBreed exists in IR"); + assert_eq!( + render_type_reference(pet_catalog_pets_by_breed), + "Record" + ); + + let pet_catalog_scope = additional_properties_ir + .schemas + .iter() + .find_map(|symbol| match &symbol.body { + SchemaType::InlineObject { properties } if symbol.name.as_ref() == "PetCatalog" => properties + .iter() + .find(|property| property.name.as_ref() == "scope") + .map(|property| &property.ty), + _ => None, + }) + .expect("PetCatalog.scope exists in IR"); + assert_eq!( + render_type_reference(pet_catalog_scope), + "'available' | 'adopted' | 'foster'" + ); + + let pet_metadata_by_tag = additional_properties_ir + .schemas + .iter() + .find_map(|symbol| { + if symbol.name.as_ref() == "PetMetadataByTag" { + Some(&symbol.body) + } else { + None + } + }) + .expect("PetMetadataByTag alias exists in IR"); + assert_eq!( + render_type_reference(pet_metadata_by_tag), + "Record" + ); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..232a945 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,39 @@ +#![deny(clippy::all)] + +mod bindings; +mod emit; +mod error; +mod io; +mod ir; +mod options; +mod parse; +mod pipeline; +pub mod plan; +mod result; +#[cfg(test)] +mod test_support; + +use napi::Env; +use napi_derive::napi; + +pub use crate::bindings::{EmitTarget, GenerateErrorPayload, GenerateOptions, GenerateResult}; +use crate::bindings::{map_failure, map_generate_result, map_panic}; +pub use crate::options::{GenerateConfig, MappedType}; +pub use crate::pipeline::execute_generate; +pub use crate::plan::naming::NamingConfig; + +#[napi(js_name = "generate")] +pub fn generate(env: Env, options: GenerateOptions) -> napi::Result { + let config = GenerateConfig::from(options); + // `catch_unwind` ensures a Rust panic inside the pipeline becomes a + // typed `E_UNEXPECTED` GenerateError rather than aborting the host + // Node process. `AssertUnwindSafe` is sound here because `config` + // is consumed by value and nothing the closure touches is observed + // after the unwind path. + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| execute_generate(config))); + match outcome { + Ok(Ok(result)) => Ok(map_generate_result(result)), + Ok(Err(failure)) => Err(map_failure(failure, env)), + Err(panic_payload) => Err(map_panic(panic_payload, env)), + } +} diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..34150d8 --- /dev/null +++ b/src/options.rs @@ -0,0 +1,652 @@ +use std::collections::BTreeSet; + +use napi_derive::napi; + +use crate::{ + bindings::{EmitTarget, InputFormat, NamingOptions}, + error::{Diagnostic, DiagnosticCode, Reporter}, +}; + +/// Canonical mapped-type record. Used as user input (from CLI/JS options) +/// and as the planning record (after schema-name validation). +/// +/// Field names match the CLI YAML config vocabulary (schema/import/type/ +/// alias). `ty` is the Rust-side name; the NAPI surface renames it to +/// `type` so the JS API stays idiomatic. +#[napi(object)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MappedType { + pub schema: String, + pub import: String, + #[napi(js_name = "type")] + pub ty: String, + pub alias: Option, +} + +/// User mapping: override the response-kind decoded for a specific +/// response content-type. Pure data — Phase-3 normalize-side reads +/// this when picking the `responseKind` for an operation's response +/// content. Keys are matched case-insensitively against the lowercased +/// media-type from the spec; the `responseType` is one of the JS-facing +/// HttpClient response kinds (`'json' | 'blob' | 'text' | 'arrayBuffer'`). +#[napi(object)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResponseTypeMapping { + pub content_type: String, + pub response_type: ResponseType, +} + +/// JS-facing response-kind values. Mirrors the names Angular's +/// `HttpClient.request({ responseType })` and `httpResource.()` +/// expose, so the config vocabulary stays in JS conventions. The emit +/// boundary translates `ArrayBuffer` to the lowercase `'arraybuffer'` +/// string `HttpClient.request` requires. +#[napi(string_enum = "camelCase")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResponseType { + Json, + Blob, + Text, + ArrayBuffer, +} + +/// Resolved generation config. The `emit` set replaces three booleans: +/// callers (and the validator/pipeline) read membership with +/// `emit.contains(&EmitTarget::Models)`. +#[derive(Clone, Debug)] +pub struct GenerateConfig { + /// Set when the caller passed `input_path`; mutually exclusive with + /// `input_contents` (validated in `validate_generate_config`). + pub input_path: Option, + pub input_contents: Option, + pub display_path: Option, + pub input_format: Option, + pub output_path: Option, + pub emit: BTreeSet, + pub mapped_types: Vec, + pub response_type_mapping: Vec, + pub naming_options: Option, + pub naming: crate::plan::naming::NamingConfig, +} + +pub(crate) fn validate_generate_config( + config: &mut GenerateConfig, + reporter: &mut Reporter<'_>, +) -> Result<(), Diagnostic> { + // Exactly one of inputPath / inputContents must be set. + match (config.input_path.is_some(), config.input_contents.is_some()) { + (true, true) | (false, false) => { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "Must set exactly one of inputPath or inputContents.", + )); + } + _ => {} + } + if config.input_contents.is_some() && config.display_path.is_none() { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "displayPath is required when inputContents is set.", + )); + } + if config.input_format.is_some() && config.input_path.is_some() { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "inputFormat is only honoured with inputContents; \ + remove it or switch to inputContents.", + )); + } + + validate_emit_targets(&mut config.emit, reporter)?; + validate_mapped_types(&config.mapped_types, reporter)?; + validate_response_type_mapping(&config.response_type_mapping, reporter)?; + config.naming = resolve_naming_options(config.naming_options.take(), reporter)?; + + // `output_path` is either omitted (in-memory only) or a real path. An empty + // string is never a valid path — reject it outright instead of silently + // coercing to in-memory. + if matches!(config.output_path.as_deref(), Some("")) { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "outputPath must be a non-empty path. Omit the field (or pass undefined) to generate in-memory.", + )); + } + Ok(()) +} + +fn validate_emit_targets( + emit: &mut BTreeSet, + reporter: &mut Reporter<'_>, +) -> Result<(), Diagnostic> { + if emit.is_empty() { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "emit must include at least one target ('models' or 'angular').", + )); + } + // Angular services reference the generated model types. Auto-include + // `models` and warn rather than rejecting the caller's emit set. + if emit.contains(&EmitTarget::Angular) && !emit.contains(&EmitTarget::Models) { + emit.insert(EmitTarget::Models); + reporter.warning( + DiagnosticCode::InvalidOption, + None, + "Auto-included 'models' in emit because 'angular' depends on it. Add 'models' to emit to silence this warning.", + ); + } + Ok(()) +} + +fn validate_mapped_types( + mapped_types: &[MappedType], + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + let mut seen = std::collections::BTreeSet::<&str>::new(); + for mapped_type in mapped_types { + if mapped_type.schema.trim().is_empty() + || mapped_type.import.trim().is_empty() + || mapped_type.ty.trim().is_empty() + { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "Failed to resolve generation options: mapped type entries require schema, import, and type.", + )); + } + + if !is_valid_ts_identifier(&mapped_type.ty) { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + format!( + "Failed to resolve generation options: mapped type type '{}' is not a valid TypeScript identifier (expected /^[A-Za-z_$][A-Za-z0-9_$]*$/).", + mapped_type.ty, + ), + )); + } + + if let Some(alias) = mapped_type.alias.as_deref() + && !is_valid_ts_identifier(alias) + { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + format!( + "Failed to resolve generation options: mapped type alias '{alias}' is not a valid TypeScript identifier." + ), + )); + } + + if !seen.insert(mapped_type.schema.as_str()) { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + format!( + "Failed to resolve generation options: mapped type schema '{}' is duplicated; each schema must appear at most once.", + mapped_type.schema, + ), + )); + } + } + + Ok(()) +} + +fn validate_response_type_mapping( + mappings: &[ResponseTypeMapping], + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + let mut seen = std::collections::BTreeSet::::new(); + for m in mappings { + let lc = m.content_type.to_ascii_lowercase(); + if lc.is_empty() { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + "responseTypeMapping.contentType must be non-empty.", + )); + } + if !lc.contains('/') { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + format!("responseTypeMapping.contentType {lc:?} must contain '/'."), + )); + } + if !seen.insert(lc.clone()) { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + format!("responseTypeMapping has duplicate contentType {lc:?} (case-insensitive)."), + )); + } + } + Ok(()) +} + +fn is_valid_ts_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +pub(crate) fn resolve_naming_options( + options: Option, + reporter: &Reporter<'_>, +) -> Result { + use crate::plan::naming::{Case, Naming, NamingConfig, Rule, RuleEntry, compile_parse_spec}; + + let Some(opts) = options else { + return Ok(NamingConfig::default()); + }; + + fn lower_entry( + string: Option, + rule: Option, + reporter: &Reporter<'_>, + path: &str, + ) -> Result { + match (string, rule) { + (Some(s), None) => Ok(RuleEntry::Shorthand(s)), + (None, Some(r)) => { + let case = match r.case_.as_deref() { + None => None, + Some(s) => Some(Case::parse(s).ok_or_else(|| { + reporter.error( + DiagnosticCode::InvalidOption, + format!( + "naming.{path}.case: '{s}' is not one of 'camel', 'pascal', 'snake', 'kebab', 'constant'.", + ), + ) + })?), + }; + let parse = r + .parse + .map(|spec| { + compile_parse_spec(&spec.source, &spec.flags).map_err(|err| { + reporter.error( + DiagnosticCode::InvalidOption, + format!( + "naming.{path}.parse: failed to compile regex `{}` (flags=`{}`): {:?}", + spec.source, spec.flags, err, + ), + ) + }) + }) + .transpose()?; + if parse.is_some() && r.format.is_none() { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + format!("naming.{path}: when `parse` is present, `format` is required."), + )); + } + Ok(RuleEntry::Rule(Rule { + from: r.from, + parse, + format: r.format, + case, + })) + } + (Some(_), Some(_)) => Err(reporter.error( + DiagnosticCode::InvalidOption, + format!("naming.{path}: a chain item cannot set both `string` and `rule`."), + )), + (None, None) => Err(reporter.error( + DiagnosticCode::InvalidOption, + format!("naming.{path}: a chain item must set exactly one of `string` or `rule`."), + )), + } + } + + fn lower_value( + value: Option, + reporter: &Reporter<'_>, + key: &str, + ) -> Result, Diagnostic> { + let Some(v) = value else { + return Ok(None); + }; + let count = + u8::from(v.string.is_some()) + u8::from(v.rule.is_some()) + u8::from(v.chain.is_some()); + if count != 1 { + return Err(reporter.error( + DiagnosticCode::InvalidOption, + format!( + "naming.{key}: must set exactly one of `string`, `rule`, or `chain` (got {count})." + ), + )); + } + if let Some(s) = v.string { + return Ok(Some(Naming::Single(RuleEntry::Shorthand(s)))); + } + if let Some(r) = v.rule { + let entry = lower_entry(None, Some(r), reporter, key)?; + return Ok(Some(Naming::Single(entry))); + } + let Some(items) = v.chain else { + unreachable!("count==1 guarantees v.chain is Some when string/rule are None") + }; + let mut entries = Vec::with_capacity(items.len()); + for (i, item) in items.into_iter().enumerate() { + let path = format!("{key}[{i}]"); + entries.push(lower_entry(item.string, item.rule, reporter, &path)?); + } + Ok(Some(Naming::Chain(entries))) + } + + Ok(NamingConfig { + method_name: lower_value(opts.method_name, reporter, "methodName")?, + group: lower_value(opts.group, reporter, "group")?, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + GenerateConfig, MappedType, ResponseType, ResponseTypeMapping, validate_generate_config, + }; + use crate::bindings::EmitTarget; + use crate::test_support::test_ctx; + + fn config(input_path: &str) -> GenerateConfig { + GenerateConfig { + input_path: Some(input_path.to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: Some("out".to_string()), + emit: [EmitTarget::Models, EmitTarget::Angular] + .into_iter() + .collect(), + mapped_types: Vec::new(), + response_type_mapping: Vec::new(), + naming_options: None, + naming: crate::plan::naming::NamingConfig::default(), + } + } + + fn config_with_mappings(mappings: Vec) -> GenerateConfig { + GenerateConfig { + response_type_mapping: mappings, + ..config("spec.yaml") + } + } + + #[test] + fn validator_accepts_in_memory_default_when_output_path_is_omitted() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + output_path: None, + emit: [EmitTarget::Models].into_iter().collect(), + ..config("spec.yaml") + }; + validate_generate_config(&mut config, &mut ctx.reporter()) + .expect("generate options should validate"); + + assert_eq!(config.input_path.as_deref(), Some("spec.yaml")); + assert_eq!(config.output_path, None); + assert!(config.emit.contains(&EmitTarget::Models)); + assert!(!config.emit.contains(&EmitTarget::Angular)); + assert!(config.mapped_types.is_empty()); + } + + #[test] + fn validator_rejects_empty_string_output_path_as_invalid_option() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + output_path: Some(String::new()), + ..config("spec.yaml") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("empty outputPath should fail during option validation"); + + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("non-empty path")); + } + + #[test] + fn validator_rejects_empty_emit_set() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + emit: std::collections::BTreeSet::new(), + ..config("spec.yaml") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("empty emit set should fail during option validation"); + + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("emit")); + } + + #[test] + fn validator_auto_includes_models_when_angular_is_requested_alone() { + let mut warnings = Vec::new(); + let path: std::rc::Rc = std::rc::Rc::from("spec.yaml"); + let mut reporter = crate::error::Reporter::new(path, &mut warnings); + let mut config = GenerateConfig { + emit: std::iter::once(EmitTarget::Angular).collect(), + ..config("spec.yaml") + }; + validate_generate_config(&mut config, &mut reporter) + .expect("auto-include should be a warning, not a fatal"); + + assert!(config.emit.contains(&EmitTarget::Models)); + assert_eq!(warnings.len(), 1); + assert_eq!( + warnings[0].code, + crate::error::DiagnosticCode::InvalidOption + ); + assert!(warnings[0].message.contains("Auto-included 'models'")); + assert!(warnings[0].message.contains("'angular'")); + } + + #[test] + fn validator_emits_no_warning_when_models_already_present() { + let mut warnings = Vec::new(); + let path: std::rc::Rc = std::rc::Rc::from("spec.yaml"); + let mut reporter = crate::error::Reporter::new(path, &mut warnings); + let mut config = GenerateConfig { + emit: [EmitTarget::Models, EmitTarget::Angular] + .into_iter() + .collect(), + ..config("spec.yaml") + }; + validate_generate_config(&mut config, &mut reporter).expect("explicit models silences warning"); + + assert!(warnings.is_empty()); + } + + #[test] + fn validator_rejects_blank_mapped_type_entries_as_invalid_option() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + mapped_types: vec![MappedType { + schema: "UserId".to_string(), + import: " ".to_string(), + ty: "ExternalUserId".to_string(), + alias: None, + }], + ..config("spec.yaml") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("blank mapped type fields should fail during option validation"); + + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("schema, import, and type")); + } + + #[test] + fn validator_rejects_naming_chain_item_with_both_string_and_rule() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + naming_options: Some(crate::bindings::NamingOptions { + method_name: Some(crate::bindings::NamingValue { + string: Some("x".to_string()), + rule: Some(crate::bindings::NamingRuleEntry { + from: None, + parse: None, + format: Some("y".to_string()), + case_: None, + }), + chain: None, + }), + group: None, + }), + ..config("spec.yaml") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("exclusive fields should fail"); + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("exactly one")); + } + + #[test] + fn validator_rejects_parse_without_format() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + naming_options: Some(crate::bindings::NamingOptions { + method_name: Some(crate::bindings::NamingValue { + string: None, + rule: Some(crate::bindings::NamingRuleEntry { + from: Some("{operationId}".to_string()), + parse: Some(crate::bindings::NamingParseSpec { + source: "^(?.+)$".to_string(), + flags: String::new(), + }), + format: None, + case_: None, + }), + chain: None, + }), + group: None, + }), + ..config("spec.yaml") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("parse without format should fail"); + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("`format` is required")); + } + + #[test] + fn validator_rejects_both_input_path_and_input_contents_set() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + input_path: Some("spec.yaml".to_string()), + input_contents: Some("openapi: 3.0.3\n".to_string()), + display_path: Some("inline".to_string()), + ..config("spec.yaml") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("input_path + input_contents must be rejected"); + + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("exactly one")); + assert!(error.message.contains("inputPath")); + assert!(error.message.contains("inputContents")); + } + + #[test] + fn validator_rejects_neither_input_path_nor_input_contents_set() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + input_path: None, + input_contents: None, + ..config("ignored") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("missing both inputs must be rejected"); + + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("exactly one")); + } + + #[test] + fn validator_rejects_input_contents_without_display_path() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + input_path: None, + input_contents: Some("openapi: 3.0.3\n".to_string()), + display_path: None, + ..config("ignored") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("inputContents without displayPath must be rejected"); + + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("displayPath")); + assert!(error.message.contains("inputContents")); + } + + #[test] + fn validator_rejects_input_format_with_input_path() { + let mut ctx = test_ctx(); + let mut config = GenerateConfig { + input_path: Some("spec.yaml".to_string()), + input_format: Some(crate::bindings::InputFormat::Json), + ..config("spec.yaml") + }; + let error = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("inputFormat with inputPath must be rejected"); + + assert_eq!(error.code, crate::error::DiagnosticCode::InvalidOption); + assert!(error.message.contains("inputFormat")); + assert!(error.message.contains("inputContents")); + } + + #[test] + fn rejects_empty_content_type_string() { + let mut ctx = test_ctx(); + let mut config = config_with_mappings(vec![ResponseTypeMapping { + content_type: "".into(), + response_type: ResponseType::Blob, + }]); + let err = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("empty contentType should fail"); + assert_eq!(err.code, crate::error::DiagnosticCode::InvalidOption); + } + + #[test] + fn rejects_duplicate_content_type_after_lowercase_normalisation() { + let mut ctx = test_ctx(); + let mut config = config_with_mappings(vec![ + ResponseTypeMapping { + content_type: "application/PDF".into(), + response_type: ResponseType::Blob, + }, + ResponseTypeMapping { + content_type: "application/pdf".into(), + response_type: ResponseType::ArrayBuffer, + }, + ]); + let err = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("duplicate contentType should fail"); + assert!(err.message.contains("application/pdf")); + } + + #[test] + fn rejects_content_type_without_slash() { + let mut ctx = test_ctx(); + let mut config = config_with_mappings(vec![ResponseTypeMapping { + content_type: "notamediatype".into(), + response_type: ResponseType::Blob, + }]); + let err = validate_generate_config(&mut config, &mut ctx.reporter()) + .expect_err("contentType without '/' should fail"); + assert!(err.message.contains("must contain")); + } + + #[test] + fn accepts_well_formed_response_type_mapping() { + let mut ctx = test_ctx(); + let mut config = config_with_mappings(vec![ + ResponseTypeMapping { + content_type: "application/pdf".into(), + response_type: ResponseType::Blob, + }, + ResponseTypeMapping { + content_type: "text/csv".into(), + response_type: ResponseType::Text, + }, + ]); + validate_generate_config(&mut config, &mut ctx.reporter()).expect("well-formed mapping passes"); + } +} diff --git a/src/parse/input.rs b/src/parse/input.rs new file mode 100644 index 0000000..03a2d98 --- /dev/null +++ b/src/parse/input.rs @@ -0,0 +1,835 @@ +use std::{fs, path::Path, path::PathBuf, rc::Rc, sync::OnceLock}; + +use crate::{ + bindings::InputFormat, + error::{Diagnostic, DiagnosticCode}, + parse::openapi_model::OpenApiDocument, +}; + +const DEFAULT_MAX_INPUT_BYTES: u64 = 16 * 1024 * 1024; +pub(crate) const DEFAULT_MAX_SCHEMAS: usize = 10_000; +pub(crate) const DEFAULT_MAX_OPERATIONS: usize = 10_000; +/// Maximum acceptable ratio of YAML-re-serialised parsed bytes to source +/// bytes. Anchors that fan out 50× or more from source are rejected before +/// the typed parse runs — see `decode_openapi_input`. The default is sized +/// well above any legitimate spec (Swagger Petstore re-serialises near 1×; +/// hand-written specs that lean on anchors stay well under 10×). +pub(crate) const DEFAULT_MAX_EXPANSION_RATIO: usize = 50; + +/// Parse the cap value from an optional env-var string. Returns the default +/// when the argument is `None` or not a valid `u64`. +fn max_input_bytes_from(env: Option<&str>) -> u64 { + env + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_MAX_INPUT_BYTES) +} + +/// Process-lifetime cached cap. Reads `OPENAPI_NG_MAX_INPUT_BYTES` exactly +/// once and falls back to `DEFAULT_MAX_INPUT_BYTES` on parse failure or +/// absence. +fn max_input_bytes() -> u64 { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + max_input_bytes_from(std::env::var("OPENAPI_NG_MAX_INPUT_BYTES").ok().as_deref()) + }) +} + +/// Parse the schemas cap from an optional env-var string. Returns the +/// default when the argument is `None` or not a valid `usize`. Mirrors +/// `max_input_bytes_from` so policy-side cap checks stay testable without +/// touching process env state. +pub(crate) fn max_schemas_from(env: Option<&str>) -> usize { + env + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_MAX_SCHEMAS) +} + +/// Parse the operations cap from an optional env-var string. Returns the +/// default when the argument is `None` or not a valid `usize`. +pub(crate) fn max_operations_from(env: Option<&str>) -> usize { + env + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_MAX_OPERATIONS) +} + +/// Process-lifetime cached schemas cap. Reads `OPENAPI_NG_MAX_SCHEMAS` once +/// per process and falls back to `DEFAULT_MAX_SCHEMAS` on parse failure or +/// absence. +pub(crate) fn max_schemas() -> usize { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| max_schemas_from(std::env::var("OPENAPI_NG_MAX_SCHEMAS").ok().as_deref())) +} + +/// Process-lifetime cached operations cap. Reads `OPENAPI_NG_MAX_OPERATIONS` +/// once per process and falls back to `DEFAULT_MAX_OPERATIONS` on parse +/// failure or absence. +pub(crate) fn max_operations() -> usize { + static CACHED: OnceLock = OnceLock::new(); + *CACHED + .get_or_init(|| max_operations_from(std::env::var("OPENAPI_NG_MAX_OPERATIONS").ok().as_deref())) +} + +/// Parse the expansion-ratio cap from an optional env-var string. Returns the +/// default when the argument is `None` or not a valid `usize`. Mirrors the +/// other cap helpers so the expansion guard stays testable without touching +/// process env state. +pub(crate) fn max_expansion_ratio_from(env: Option<&str>) -> usize { + env + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_MAX_EXPANSION_RATIO) +} + +/// Process-lifetime cached expansion-ratio cap. Reads +/// `OPENAPI_NG_MAX_EXPANSION_RATIO` once per process and falls back to +/// `DEFAULT_MAX_EXPANSION_RATIO` on parse failure or absence. +pub(crate) fn max_expansion_ratio() -> usize { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + max_expansion_ratio_from( + std::env::var("OPENAPI_NG_MAX_EXPANSION_RATIO") + .ok() + .as_deref(), + ) + }) +} + +/// Read the input file and decode it into a typed `OpenApiDocument`. The +/// display path is owned by the pipeline boundary (`execute_generate`) and +/// passed in so every diagnostic — read, decode, normalize, plan, write — +/// carries the exact same `Rc` without re-deriving it at each layer. +pub(crate) fn read_and_decode( + input_path: &str, + display_path: &Rc, +) -> Result { + let path = PathBuf::from(input_path); + + let metadata = fs::metadata(&path).map_err(|error| { + Diagnostic::new( + DiagnosticCode::InputInvalid, + format!("Failed to read OpenAPI input: {error}"), + Rc::clone(display_path), + ) + })?; + let max_bytes = max_input_bytes(); + if metadata.len() > max_bytes { + return Err(Diagnostic::new( + DiagnosticCode::InputInvalid, + format!( + "Failed to read OpenAPI input: file is {} bytes, exceeds maximum of {} bytes. \ + Set OPENAPI_NG_MAX_INPUT_BYTES to override.", + metadata.len(), + max_bytes, + ), + Rc::clone(display_path), + )); + } + + let source = fs::read_to_string(&path).map_err(|error| { + Diagnostic::new( + DiagnosticCode::InputInvalid, + format!("Failed to read OpenAPI input: {error}"), + Rc::clone(display_path), + ) + })?; + decode_openapi_input(&path, &source, display_path) +} + +pub(crate) fn decode_openapi_input( + path: &Path, + source: &str, + display_path: &Rc, +) -> Result { + decode_openapi_input_with_hint(path, source, display_path, None) +} + +/// Entry point for the `inputContents` branch. Enforces the byte cap on +/// the supplied source (the 16 MiB default that `read_and_decode` enforces +/// for file inputs via `fs::metadata().len()` — without this check a +/// caller who bypasses the JS-side fetch cap could pass an arbitrarily +/// large string), then delegates to the hint-aware decoder. +/// +/// The synthetic `Path::new("")` is fine: when `hint` is `Some`, the +/// decoder skips extension lookup entirely; when `hint` is `None`, the +/// extension is `None` and the decoder falls through to the +/// sniff-both-parsers branch (which is the desired behaviour for +/// hint-less inputContents anyway). +pub(crate) fn decode_input_contents( + source: &str, + hint: Option, + display_path: &Rc, +) -> Result { + let len_bytes = source.len(); + let max_bytes = max_input_bytes(); + if (len_bytes as u64) > max_bytes { + return Err(Diagnostic::new( + DiagnosticCode::InputInvalid, + format!( + "OpenAPI input is {len_bytes} bytes, exceeds maximum of {max_bytes} bytes. \ + Set OPENAPI_NG_MAX_INPUT_BYTES to override.", + ), + Rc::clone(display_path), + )); + } + decode_openapi_input_with_hint(std::path::Path::new(""), source, display_path, hint) +} + +pub(crate) fn decode_openapi_input_with_hint( + path: &Path, + source: &str, + display_path: &Rc, + hint: Option, +) -> Result { + // Explicit hint wins over extension/sniff. + if let Some(format) = hint { + return match format { + InputFormat::Json => serde_json::from_str(source).map_err(|error| { + Diagnostic::new( + DiagnosticCode::InputInvalid, + format!("Failed to decode OpenAPI input as JSON: {error}"), + Rc::clone(display_path), + ) + }), + InputFormat::Yaml => decode_yaml(source, display_path), + }; + } + + // No hint: extension-based dispatch (unchanged behaviour). + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase); + + // Both `serde_json::Error` and `serde_yml::Error` already include the + // source position ("at line X column Y") in their `Display` impls, so we + // forward the raw error text verbatim — adding our own `(line X, column Y)` + // prefix would just duplicate what serde already prints. If we ever switch + // to a parser that omits position info, lift `err.line()/err.column()` + // (serde_json) or `err.location()` (serde_yml) into the message here. + match extension.as_deref() { + Some("json") => serde_json::from_str(source).map_err(|error| { + Diagnostic::new( + DiagnosticCode::InputInvalid, + format!("Failed to decode OpenAPI input as JSON: {error}"), + Rc::clone(display_path), + ) + }), + Some("yaml" | "yml") => decode_yaml(source, display_path), + _ => serde_json::from_str(source) + .or_else(|_| serde_yml::from_str(source)) + .map_err(|yaml_error| { + Diagnostic::new( + DiagnosticCode::InputInvalid, + format!( + "Failed to decode OpenAPI input as JSON or YAML: {yaml_error}. \ + Rename the file with a .json, .yaml, or .yml extension so the decoder can pick the right parser.", + ), + Rc::clone(display_path), + ) + }), + } +} + +/// Decode a YAML source into an `OpenApiDocument`, applying the duplicate-key +/// and anchor-fanout guards. Sequencing rationale, post-T4.1: +/// +/// 1. **Value parse always runs.** It is required by both behavioural +/// guarantees: the typed `BTreeMap` deserialiser silently last-wins on +/// duplicate keys, so we need the Value-side "duplicate entry" error to +/// surface the `duplicate-schema-name` diagnostic; and the expansion +/// guard from T3.4 needs the parsed Value to measure post-decode size. +/// The duplicate-key fixture itself has no `&`, so we cannot gate the +/// Value parse on anchor presence without regressing that diagnostic. +/// +/// 2. **`to_string` re-serialisation is gated on `source.contains('&')`.** +/// That is the genuinely expensive part of T3.4's expansion guard — for +/// a document with no anchors the re-serialised output is bytewise +/// close to the source and the guard is structurally unreachable. +/// Skipping the re-serialisation eliminates the bulk of the T3.4 cost +/// on every anchor-free spec (the common case) without weakening +/// defense on the anchor path. `&` may appear inside string literals; +/// the false-positive is harmless (we just pay the re-serialisation +/// once on a spec that has no real anchors). +/// +/// 3. **Typed parse runs last**, on the original source (serde decodes from +/// `&str`, not from a `Value`), and its error carries the field-path +/// context users expect. +/// +/// The T4.1 plan called for "typed-first, Value-fallback only on error", +/// but that ordering pre-dated T3.4 and breaks both the duplicate-key +/// detection (typed never fails on duplicates) and the expansion guard +/// (needs the Value). The `&`-gated re-serialisation is the cleanest +/// reconciliation: the Value parse stays cheap, the re-serialisation is +/// elided on the no-anchor common case. +fn decode_yaml(source: &str, display_path: &Rc) -> Result { + // Step 1: Value parse — catches duplicate mapping keys. `serde_yml` rejects + // duplicate keys when deserialising to `Value` (which preserves key + // ordering) but silently last-wins into a `BTreeMap`. We exploit this + // difference here. + match serde_yml::from_str::(source) { + Err(value_err) => { + let msg = value_err.to_string(); + if msg.contains("duplicate entry") { + // The serde_yml error message format for a duplicate key in a + // mapping deserialised as `Value` is: + // ": duplicate entry with key \"\" at line N column M" + // Extract the key name from between the quotes. + let key_name = extract_duplicate_key_name(&msg).unwrap_or(""); + return Err(Diagnostic { + code: DiagnosticCode::PolicyViolation, + subcode: Some("duplicate-schema-name"), + message: format!( + "Failed to decode OpenAPI input: schema name '{key_name}' is defined more than once in components.schemas.", + ), + path: Rc::clone(display_path), + }); + } + // Non-duplicate Value error: fall through to the typed decode below so + // the message carries field-path context. + } + Ok(value) => { + // Step 2: anchor-fanout guard. The re-serialisation is the expensive + // operation; skip it entirely when the source has no anchor markers, + // since the guard is structurally unreachable on anchor-free input. + // This is the T4.1 perf win: anchor-free specs pay only the Value + // parse, not the re-serialisation. + if source.contains('&') + && let Ok(expanded) = serde_yml::to_string(&value) + { + let source_len = source.len().max(1); + let cap = max_expansion_ratio(); + // Saturating arithmetic on the cap multiplication: source.len() is + // already bounded by the input-byte cap upstream, but the product + // could overflow on a pathologically small source × huge cap. + let threshold = source_len.saturating_mul(cap); + if expanded.len() > threshold { + let ratio = expanded.len() / source_len; + return Err(Diagnostic { + code: DiagnosticCode::PolicyViolation, + subcode: Some("mapping-expansion-exceeded"), + message: format!( + "Failed to decode OpenAPI input: YAML anchor expansion produced {expanded_len} bytes from {source_len} bytes of source — {ratio}× ratio exceeds the cap of {cap}×. The spec likely uses anchors with deep fan-out; inline the aliases or set OPENAPI_NG_MAX_EXPANSION_RATIO to override.", + expanded_len = expanded.len(), + ), + path: Rc::clone(display_path), + }); + } + } + } + } + + // Step 3: typed decode on the original source. serde_yml deserialises from + // `&str`, not from a `Value`, so this is a second parse of the same bytes. + // Field-path context lives in the typed decoder's error path. + serde_yml::from_str(source).map_err(|error| { + Diagnostic::new( + DiagnosticCode::InputInvalid, + format!("Failed to decode OpenAPI input as YAML: {error}"), + Rc::clone(display_path), + ) + }) +} + +/// Extract the duplicate key name from a `serde_yml` "duplicate entry" error +/// message. The message format is: +/// ": duplicate entry with key \"\" at line N column M" +/// Returns the text between the first pair of double-quotes, or `None` if the +/// pattern is not found (defensive fallback). +fn extract_duplicate_key_name(msg: &str) -> Option<&str> { + let start = msg.find('"')?; + let end = msg[start + 1..].find('"')?; + Some(&msg[start + 1..start + 1 + end]) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + rc::Rc, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::{ + DEFAULT_MAX_INPUT_BYTES, decode_openapi_input, max_input_bytes_from, read_and_decode, + }; + use crate::error::DiagnosticCode; + use std::path::PathBuf; + + #[test] + fn read_and_decode_returns_typed_document_for_supported_input() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock works") + .as_nanos(); + let path = std::env::temp_dir().join(format!("openapi-ng-read-and-decode-{nanos}.json")); + fs::write( + &path, + r#"{"openapi":"3.0.3","info":{"title":"Decode","version":"1.0.0"},"paths":{}}"#, + ) + .expect("fixture should be written"); + + let path_str = path.to_str().expect("utf-8 path"); + let display: Rc = Rc::from(path_str); + let document = read_and_decode(path_str, &display).expect("decode should succeed"); + + assert_eq!(document.info.title, "Decode"); + assert_eq!(document.openapi, "3.0.3"); + + let _ = fs::remove_file(path); + } + + // Regression guard for Phase 4.4: the user-facing decode message must + // surface the source position so authors can jump to the offending byte + // without re-parsing the file by hand. `serde_json::Error::Display` already + // appends "at line X column Y"; if a future upgrade drops that, this test + // fails and forces us to construct the position ourselves. + #[test] + fn decode_error_for_malformed_json_includes_line_and_column() { + let path = PathBuf::from("spec.json"); + let display: Rc = Rc::from("spec.json"); + let source = "{\"openapi\": \"3.0.3\", \"info\":}"; + let err = + decode_openapi_input(&path, source, &display).expect_err("malformed JSON must fail decode"); + + assert!( + err.message.contains("line ") && err.message.contains("column "), + "expected line/column in JSON decode error, got: {message}", + message = err.message, + ); + } + + #[test] + fn decode_error_for_malformed_yaml_includes_line_and_column() { + let path = PathBuf::from("spec.yaml"); + let display: Rc = Rc::from("spec.yaml"); + let source = "openapi: 3.0.3\ninfo:\n title: M\n version: 1.0.0\npaths:\n broken: [\n"; + let err = + decode_openapi_input(&path, source, &display).expect_err("malformed YAML must fail decode"); + + assert!( + err.message.contains("line ") && err.message.contains("column "), + "expected line/column in YAML decode error, got: {message}", + message = err.message, + ); + } + + // Inline-source variant of the duplicate-key regression: pins behaviour + // independently of the fixture file. Together with + // `duplicate_schema_name_is_rejected_in_yaml`, this guards against silent + // BTreeMap last-wins regressions if T4.1's typed-first reorder ever drops + // the Value-parse probe on the no-anchor success path. + #[test] + fn duplicate_schema_name_in_yaml_is_diagnosed() { + let yaml = r#" +openapi: 3.0.3 +info: { title: t, version: '1.0.0' } +paths: {} +components: + schemas: + Pet: { type: object } + Pet: { type: string } +"#; + let path = PathBuf::from("inline.yaml"); + let display: Rc = Rc::from("inline.yaml"); + let err = decode_openapi_input(&path, yaml, &display) + .expect_err("inline duplicate-key YAML should be diagnosed"); + assert_eq!(err.code, DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("duplicate-schema-name")); + assert!( + err.message.contains("Pet"), + "expected duplicate key 'Pet' in message: {}", + err.message, + ); + } + + #[test] + fn duplicate_schema_name_is_rejected_in_yaml() { + let yaml = include_str!("../../test/fixtures/duplicate-schema-name.openapi.yaml"); + let path = PathBuf::from("dup.yaml"); + let display: Rc = Rc::from("dup.yaml"); + let err = + decode_openapi_input(&path, yaml, &display).expect_err("should reject duplicate schema name"); + assert_eq!(err.code, crate::error::DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("duplicate-schema-name")); + } + + // --- size-cap tests --- + + #[test] + fn rejects_input_larger_than_cap() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock works") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "oapi-ng-oversized-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("huge.yaml"); + + // Write 17 MiB of content so the cap fires before any parse attempt. + let header = "openapi: 3.0.3\ninfo: { title: x, version: 1.0.0 }\npaths: {}\n# "; + let pad_bytes = (17 * 1024 * 1024) - header.len(); + let mut content = String::with_capacity(17 * 1024 * 1024); + content.push_str(header); + content.push_str(&"a".repeat(pad_bytes)); + fs::write(&path, &content).unwrap(); + + let path_str = path.to_str().expect("utf-8 path"); + let display: Rc = Rc::from(path_str); + let result = read_and_decode(path_str, &display); + let _ = fs::remove_dir_all(&dir); + + let err = result.expect_err("should reject oversized input"); + assert_eq!(err.code, DiagnosticCode::InputInvalid); + assert!( + err.message.contains("exceeds maximum"), + "unexpected message: {}", + err.message + ); + } + + // Pure-function tests for the cap helper — not affected by OnceLock state. + + #[test] + fn cap_helper_default() { + assert_eq!(max_input_bytes_from(None), DEFAULT_MAX_INPUT_BYTES); + assert_eq!(max_input_bytes_from(None), 16 * 1024 * 1024); + } + + #[test] + fn cap_helper_respects_valid_env() { + assert_eq!(max_input_bytes_from(Some("1024")), 1024); + assert_eq!(max_input_bytes_from(Some("0")), 0); + } + + #[test] + fn cap_helper_rejects_invalid_env_uses_default() { + assert_eq!( + max_input_bytes_from(Some("not-a-number")), + DEFAULT_MAX_INPUT_BYTES + ); + assert_eq!(max_input_bytes_from(Some("")), DEFAULT_MAX_INPUT_BYTES); + assert_eq!(max_input_bytes_from(Some("-1")), DEFAULT_MAX_INPUT_BYTES); + } + + // --- expansion-ratio cap tests --- + + #[test] + fn max_expansion_ratio_from_default_value() { + use super::{DEFAULT_MAX_EXPANSION_RATIO, max_expansion_ratio_from}; + assert_eq!(max_expansion_ratio_from(None), DEFAULT_MAX_EXPANSION_RATIO); + assert_eq!(max_expansion_ratio_from(None), 50); + } + + #[test] + fn max_expansion_ratio_from_env_override() { + use super::{DEFAULT_MAX_EXPANSION_RATIO, max_expansion_ratio_from}; + assert_eq!(max_expansion_ratio_from(Some("100")), 100); + assert_eq!(max_expansion_ratio_from(Some("1")), 1); + assert_eq!(max_expansion_ratio_from(Some("0")), 0); + // Invalid forms fall back to default. + assert_eq!( + max_expansion_ratio_from(Some("not-a-number")), + DEFAULT_MAX_EXPANSION_RATIO, + ); + assert_eq!( + max_expansion_ratio_from(Some("")), + DEFAULT_MAX_EXPANSION_RATIO, + ); + assert_eq!( + max_expansion_ratio_from(Some("-1")), + DEFAULT_MAX_EXPANSION_RATIO, + ); + } + + #[test] + fn anchor_expansion_within_ratio_accepts() { + // A handful of aliases on a small anchor stays well under the default + // 50× expansion cap. Pins that legitimate anchor use is not regressed + // by the guard. + let yaml = r#"openapi: 3.0.3 +info: { title: modest-anchor, version: '1.0.0' } +paths: {} +components: + schemas: + Base: &b + type: object + properties: + id: { type: string } + name: { type: string } + A1: { allOf: [*b] } + A2: { allOf: [*b] } + A3: { allOf: [*b] } +"#; + let path = PathBuf::from("modest.yaml"); + let display: Rc = Rc::from("modest.yaml"); + decode_openapi_input(&path, yaml, &display).expect("modest anchor use should decode"); + } + + #[test] + fn anchor_expansion_exceeding_ratio_rejects() { + // Construct a YAML where the anchor body × alias count blows past the + // 50× ratio cap on re-serialisation. 500 A-rows × 16 aliases each × + // a ~250-byte body re-serialises into ~2 MB from a ~30 KB source + // (~70× ratio). The check is independent of the OnceLock-cached cap + // because the cap setter is `max_expansion_ratio()`; this test + // exercises the same path the cached value would. + let mut yaml = String::from( + "openapi: 3.0.3\ninfo:\n title: Fanout\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n Base: &b\n type: object\n properties:\n", + ); + for i in 0..20 { + yaml.push_str(&format!( + " prop_{i:02}: {{ type: string, description: \"property {i:02} padding text here\" }}\n", + )); + } + for r in 0..500 { + let aliases: String = std::iter::repeat("*b") + .take(16) + .collect::>() + .join(", "); + yaml.push_str(&format!(" A{r:04}: {{ allOf: [{aliases}] }}\n")); + } + + let path = PathBuf::from("fanout.yaml"); + let display: Rc = Rc::from("fanout.yaml"); + let err = decode_openapi_input(&path, &yaml, &display) + .expect_err("fanned-out anchors should be rejected"); + + assert_eq!(err.code, DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("mapping-expansion-exceeded")); + assert!( + err.message.contains("OPENAPI_NG_MAX_EXPANSION_RATIO"), + "expected env-var hint in message: {}", + err.message, + ); + assert!( + err.message.contains("anchor expansion"), + "expected anchor-expansion phrasing: {}", + err.message, + ); + } + + // Sanity check on the YAML success path: a spec with no `&` anchors + // decodes cleanly and lands every schema. This test is intentionally + // structural — it does NOT directly verify that T4.1's + // `source.contains('&')` gate skips the `to_string` re-serialisation; + // observing that skip would require `cfg(test)`-gated instrumentation on + // the decode hot path, which is out of proportion for a single assertion. + // The perf-relevant skip is verified by `pnpm bench` medians (see + // `decode_yaml`'s docstring and commit `ab550fb`); this test would still + // pass even if the gate were deleted. It guards the surrounding shape: + // that anchor-free YAML still decodes successfully through the helper. + #[test] + fn anchor_free_yaml_decodes_successfully() { + let mut yaml = String::from( + "openapi: 3.0.3\ninfo:\n title: NoAnchors\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n", + ); + for i in 0..50 { + yaml.push_str(&format!( + " S{i:03}:\n type: object\n properties:\n id: {{ type: string }}\n name: {{ type: string }}\n", + )); + } + // Sanity-check the precondition: the source contains no anchor markers. + assert!( + !yaml.contains('&'), + "fixture must be anchor-free to exercise the fast path", + ); + + let path = PathBuf::from("noanchor.yaml"); + let display: Rc = Rc::from("noanchor.yaml"); + let document = + decode_openapi_input(&path, &yaml, &display).expect("anchor-free spec should decode"); + assert_eq!( + document.components.schemas.len(), + 50, + "all 50 schemas should land in the typed doc", + ); + } + + #[test] + fn decode_openapi_input_honours_explicit_format_hint_over_extension() { + use super::decode_openapi_input_with_hint; + use crate::bindings::InputFormat; + + // File named .yaml but contents are valid JSON. With the hint we + // skip extension lookup and decode as JSON directly. + let path = PathBuf::from("misnamed.yaml"); + let display: Rc = Rc::from("misnamed.yaml"); + let json_source = + r#"{"openapi":"3.0.3","info":{"title":"Hinted","version":"1.0.0"},"paths":{}}"#; + let doc = decode_openapi_input_with_hint(&path, json_source, &display, Some(InputFormat::Json)) + .expect("explicit Json hint must decode as JSON regardless of extension"); + assert_eq!(doc.info.title, "Hinted"); + } + + #[test] + fn decode_openapi_input_with_no_hint_falls_back_to_extension() { + use super::decode_openapi_input_with_hint; + let path = PathBuf::from("spec.json"); + let display: Rc = Rc::from("spec.json"); + let source = r#"{"openapi":"3.0.3","info":{"title":"X","version":"1.0.0"},"paths":{}}"#; + let doc = decode_openapi_input_with_hint(&path, source, &display, None) + .expect("None hint should still decode JSON via extension"); + assert_eq!(doc.info.title, "X"); + } + + #[test] + fn no_extension_decode_error_includes_parser_message() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock works") + .as_nanos(); + let path = std::env::temp_dir().join(format!("oapi-ng-noext-{nanos}")); // no extension + // Use a tab character inside a flow mapping — syntactically invalid in both JSON and YAML. + fs::write(&path, "{\t\"key\": [}").unwrap(); + + let path_str = path.to_str().expect("utf-8 path"); + let display: Rc = Rc::from(path_str); + let err = read_and_decode(path_str, &display).expect_err("should fail"); + let _ = fs::remove_file(&path); + + let msg = &err.message; + // The "Rename" hint must still be present. + assert!(msg.contains("Rename"), "missing Rename hint: {msg}"); + // The underlying parser error info should be there too — serde_yml includes + // "line" and "column" in its Display output so authors can jump to the + // offending byte without re-parsing by hand. + assert!( + msg.contains("line ") && msg.contains("column "), + "expected line/column from parser in message: {msg}", + ); + } + + #[test] + fn decode_openapi_input_yaml_hint_on_json_content_fails_decode_as_yaml() { + use super::decode_openapi_input_with_hint; + use crate::bindings::InputFormat; + + // A YAML hint must force YAML decoding even when the source is + // wire-compatible JSON — this proves the hint suppresses the + // sniff fallback rather than just steering it. + // + // JSON-shaped maps happen to parse as YAML (flow-style), so we + // pick content that is unambiguously NOT yaml: a leading tab inside + // a flow mapping, which serde_yml rejects. + let path = PathBuf::from("ambiguous"); + let display: Rc = Rc::from("ambiguous"); + let source = "{\t\"openapi\": \"3.0.3\"}"; + let err = decode_openapi_input_with_hint(&path, source, &display, Some(InputFormat::Yaml)) + .expect_err("Yaml hint must route through the YAML decoder"); + assert_eq!(err.code, DiagnosticCode::InputInvalid); + assert!( + err.message.contains("YAML"), + "expected YAML decoder error, got: {}", + err.message, + ); + } + + #[test] + fn decode_openapi_input_json_hint_with_no_extension_decodes_successfully() { + use super::decode_openapi_input_with_hint; + use crate::bindings::InputFormat; + + // No path extension and no Content-Type — but with an explicit + // Json hint the decoder should still succeed. This is the URL-input + // shape where the JS wrapper hands us inputContents + an empty path. + let path = PathBuf::from(""); + let display: Rc = Rc::from("https://example.com/openapi"); + let source = r#"{"openapi":"3.0.3","info":{"title":"NoExt","version":"1.0.0"},"paths":{}}"#; + let doc = decode_openapi_input_with_hint(&path, source, &display, Some(InputFormat::Json)) + .expect("Json hint must succeed even without a path extension"); + assert_eq!(doc.info.title, "NoExt"); + } + + #[test] + fn decode_input_contents_enforces_byte_cap() { + use super::decode_input_contents; + + // Build a string larger than the default 16 MiB cap: 17 MiB of 'a' + // padding inside an otherwise-valid YAML header. + let header = "openapi: 3.0.3\ninfo: { title: Big, version: 1.0.0 }\npaths: {}\n# "; + let pad_bytes = (17 * 1024 * 1024) - header.len(); + let mut content = String::with_capacity(17 * 1024 * 1024); + content.push_str(header); + content.push_str(&"a".repeat(pad_bytes)); + + let display: Rc = Rc::from("inline://big"); + let err = decode_input_contents(&content, None, &display) + .expect_err("oversize inputContents must be rejected"); + + assert_eq!(err.code, DiagnosticCode::InputInvalid); + assert!( + err.message.contains("exceeds maximum"), + "message: {}", + err.message, + ); + assert!( + err.message.contains("OPENAPI_NG_MAX_INPUT_BYTES"), + "expected env-var hint, got: {}", + err.message, + ); + } + + #[test] + fn decode_input_contents_under_cap_decodes_successfully() { + use super::decode_input_contents; + use crate::bindings::InputFormat; + + let source = r#"{"openapi":"3.0.3","info":{"title":"Small","version":"1.0.0"},"paths":{}}"#; + let display: Rc = Rc::from("inline://small"); + let doc = decode_input_contents(source, Some(InputFormat::Json), &display) + .expect("small JSON inputContents must decode"); + assert_eq!(doc.info.title, "Small"); + } +} + +#[cfg(test)] +mod proptests { + use std::rc::Rc; + + use proptest::prelude::*; + + use super::read_and_decode; + use crate::error::DiagnosticCode; + + proptest! { + #![proptest_config(ProptestConfig { + // Keep iteration count reasonable for CI — boundary fuzzing doesn't need millions. + cases: 256, + ..ProptestConfig::default() + })] + + #[test] + fn read_and_decode_never_panics(bytes in proptest::collection::vec(any::(), 0..16384)) { + // Write to a unique temp file per case so concurrent property invocations don't collide. + let dir = std::env::temp_dir().join(format!( + "oapi-ng-prop-decode-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + std::fs::create_dir_all(&dir).unwrap(); + // Pick an extension at random-ish to exercise both code paths. + let ext = if bytes.len() % 2 == 0 { "yaml" } else { "json" }; + let path = dir.join(format!("input.{ext}")); + std::fs::write(&path, &bytes).unwrap(); + + let path_str = path.to_str().expect("utf-8 path"); + let display: Rc = Rc::from(path_str); + let result = read_and_decode(path_str, &display); + let _ = std::fs::remove_dir_all(&dir); + + // Property: never panic. Either Ok, or Err with a typed code. + if let Err(diag) = result { + prop_assert!( + matches!(diag.code, DiagnosticCode::InputInvalid | DiagnosticCode::PolicyViolation), + "unexpected diagnostic code: {:?}", diag.code, + ); + } + } + } +} diff --git a/src/parse/mod.rs b/src/parse/mod.rs new file mode 100644 index 0000000..f192fba --- /dev/null +++ b/src/parse/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod input; +pub(crate) mod openapi_model; +pub(crate) mod policy; + +pub(crate) use input::{decode_input_contents, read_and_decode}; +pub(crate) use policy::{validate_generation_policy, validate_openapi_version}; diff --git a/src/parse/openapi_model.rs b/src/parse/openapi_model.rs new file mode 100644 index 0000000..4925195 --- /dev/null +++ b/src/parse/openapi_model.rs @@ -0,0 +1,227 @@ +use std::collections::BTreeMap; + +use indexmap::IndexMap; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +pub(crate) struct OpenApiDocument { + pub(crate) openapi: String, + pub(crate) info: OpenApiInfo, + pub(crate) paths: BTreeMap, + #[serde(default)] + pub(crate) components: Components, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct OpenApiInfo { + pub(crate) title: String, +} + +#[derive(Debug, Deserialize, Default)] +pub(crate) struct Components { + #[serde(default)] + pub(crate) schemas: BTreeMap, +} + +/// A path item in OpenAPI 3.x. Fields are in alphabetical method order to match +/// the BTreeMap ordering that the previous untyped implementation produced. +#[derive(Debug, Deserialize, Default)] +pub(crate) struct PathItem { + pub(crate) delete: Option, + pub(crate) get: Option, + pub(crate) head: Option, + pub(crate) options: Option, + pub(crate) patch: Option, + pub(crate) post: Option, + pub(crate) put: Option, + pub(crate) trace: Option, +} + +impl PathItem { + /// Iterate over all operations in this path item, yielding (method, operation) pairs. + /// Methods are yielded in alphabetical order (delete, get, head, ...) matching the + /// BTreeMap ordering of the previous untyped implementation. + pub(crate) fn operations(&self) -> impl Iterator { + [ + ("delete", self.delete.as_ref()), + ("get", self.get.as_ref()), + ("head", self.head.as_ref()), + ("options", self.options.as_ref()), + ("patch", self.patch.as_ref()), + ("post", self.post.as_ref()), + ("put", self.put.as_ref()), + ("trace", self.trace.as_ref()), + ] + .into_iter() + .filter_map(|(method, op)| op.map(|op| (method, op))) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Operation { + pub(crate) operation_id: Option, + #[serde(default)] + pub(crate) tags: Vec, + #[serde(default)] + pub(crate) parameters: Vec, + pub(crate) request_body: Option, + pub(crate) responses: Option>, + pub(crate) summary: Option, + pub(crate) description: Option, + /// OpenAPI `deprecated: true` on the operation. Emitted as `@deprecated` + /// in the JSDoc above the service method so call sites surface the IDE + /// deprecation marker. + #[serde(default)] + pub(crate) deprecated: bool, +} + +impl Operation { + /// Returns summary and description joined with a blank line, or whichever + /// is present alone. Whitespace-only values are treated as absent. + pub(crate) fn merged_description(&self) -> Option { + match ( + self + .summary + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()), + self + .description + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()), + ) { + (None, None) => None, + (Some(s), None) => Some(s.to_string()), + (None, Some(d)) => Some(d.to_string()), + (Some(s), Some(d)) => Some(format!("{s}\n\n{d}")), + } + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct Parameter { + pub(crate) name: String, + #[serde(rename = "in")] + pub(crate) location: String, + #[serde(default)] + pub(crate) required: bool, + pub(crate) schema: Option, + pub(crate) content: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RequestBody { + pub(crate) content: BTreeMap, + #[serde(default)] + pub(crate) required: bool, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MediaType { + pub(crate) schema: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct Response { + pub(crate) content: Option>, +} + +#[derive(Debug, Deserialize, Default)] +#[cfg_attr(test, derive(Clone))] +#[serde(rename_all = "camelCase")] +pub(crate) struct Schema { + #[serde(rename = "$ref")] + pub(crate) ref_: Option, + #[serde(rename = "type")] + pub(crate) type_: Option, + #[serde(rename = "enum")] + pub(crate) enum_: Option>, + pub(crate) one_of: Option>, + pub(crate) any_of: Option>, + pub(crate) all_of: Option>, + pub(crate) not: Option>, + /// Preserves spec-author insertion order so generated TypeScript matches the source document. + /// `BTreeMap` would silently re-sort properties alphabetically, destroying meaningful + /// ordering (e.g. id/name/status/tags/nickname becoming id/name/nickname/status/tags). + pub(crate) properties: Option>, + #[serde(default)] + pub(crate) required: Vec, + pub(crate) additional_properties: Option, + pub(crate) items: Option>, + pub(crate) nullable: Option, + pub(crate) discriminator: Option, + pub(crate) description: Option, + /// OpenAPI `deprecated: true` on the schema. Emitted as `@deprecated` in + /// the JSDoc above the corresponding TypeScript declaration (top-level + /// model or property) so consumers see the IDE deprecation marker at the + /// reference site. + #[serde(default)] + pub(crate) deprecated: bool, + /// OpenAPI `format` hint (e.g. `uuid`, `date-time`, `int32`). Currently + /// not carried into the IR — the schema walker surfaces every occurrence + /// as an `E_UNSUPPORTED_SEMANTIC` warning (subcode `format-dropped`) so + /// spec authors see what's being dropped instead of the field being + /// silently ignored. + pub(crate) format: Option, +} + +#[derive(Debug, Deserialize, Default)] +#[cfg_attr(test, derive(Clone))] +#[serde(rename_all = "camelCase")] +pub(crate) struct Discriminator { + pub(crate) property_name: String, + /// OpenAPI `discriminator.mapping`: maps a wire-value string to either + /// a full `$ref` (`#/components/schemas/Cat`) or a bare schema name. + /// Resolved at IR-build time to bare schema names so the emit-time + /// narrowing pass can compare against `SchemaType::Ref` payloads + /// directly. Defaults to empty when the spec omits the field. + #[serde(default)] + pub(crate) mapping: BTreeMap, +} + +#[cfg(test)] +impl Schema { + pub(crate) fn default_string() -> Self { + Self { + type_: Some("string".to_string()), + ..Default::default() + } + } + + pub(crate) fn wrap_array(items: Self) -> Self { + Self { + type_: Some("array".to_string()), + items: Some(Box::new(items)), + ..Default::default() + } + } + + pub(crate) fn wrap_one_of(members: Vec) -> Self { + Self { + one_of: Some(members), + ..Default::default() + } + } + + pub(crate) fn wrap_nullable(inner: Self) -> Self { + Self { + nullable: Some(true), + ..inner + } + } +} + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(Clone))] +#[serde(untagged)] +pub(crate) enum AdditionalProperties { + Schema(Box), + // The bool value (true vs false) is intentionally discarded — both + // forms map to the same "unsupported subset" rejection in + // normalize/schema.rs. Deserializing as a typed variant (rather than + // a generic catch-all) keeps the rejection message accurate. + Boolean(#[allow(dead_code)] bool), +} diff --git a/src/parse/policy.rs b/src/parse/policy.rs new file mode 100644 index 0000000..7bf2dbc --- /dev/null +++ b/src/parse/policy.rs @@ -0,0 +1,330 @@ +use std::collections::BTreeMap; + +use crate::{ + error::{Diagnostic, DiagnosticCode, Reporter}, + parse::{ + input::{max_operations, max_schemas}, + openapi_model::OpenApiDocument, + }, +}; + +pub(crate) fn validate_openapi_version( + document: &OpenApiDocument, + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + if !document.openapi.starts_with("3.") { + return Err(reporter.error( + DiagnosticCode::UnsupportedSemantic, + format!( + "Unsupported OpenAPI document shape: only OpenAPI 3.x documents are supported, found {}.", + document.openapi + ), + )); + } + Ok(()) +} + +pub(crate) fn validate_generation_policy( + document: &OpenApiDocument, + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + // Per-document caps. These are sized to forestall accidental + // pathological inputs (e.g. a fanned-out anchor expansion) before any + // O(n²)-ish normalize/emit work runs. The defaults are deliberately + // generous (10k each) — real specs are several orders of magnitude + // below — and overridable via env so downstream consumers can opt out + // without recompiling. + let schema_count = document.components.schemas.len(); + let cap_schemas = max_schemas(); + if schema_count > cap_schemas { + return Err(Diagnostic::policy_violation( + reporter, + "schema-cap-exceeded", + format!( + "Failed to plan services: OpenAPI document declares {schema_count} schemas under components.schemas; \ + the per-document cap is {cap_schemas}. Set OPENAPI_NG_MAX_SCHEMAS to override.", + ), + )); + } + + let operation_count: usize = document + .paths + .values() + .map(|path_item| path_item.operations().count()) + .sum(); + let cap_operations = max_operations(); + if operation_count > cap_operations { + return Err(Diagnostic::policy_violation( + reporter, + "operation-cap-exceeded", + format!( + "Failed to plan services: OpenAPI document declares {operation_count} operations across paths; \ + the per-document cap is {cap_operations}. Set OPENAPI_NG_MAX_OPERATIONS to override.", + ), + )); + } + + // Maps operationId → (method, path) for duplicate detection. + let mut seen_operation_ids: BTreeMap<&str, (&'static str, &str)> = BTreeMap::new(); + + for (path, path_item) in &document.paths { + for (method, operation) in path_item.operations() { + if operation.operation_id.is_none() { + return Err(Diagnostic::policy_violation( + reporter, + "missing-operation-id", + format!( + "Failed to plan services: operation {} {} must define operationId when service generation is enabled.", + method.to_ascii_uppercase(), + path + ), + )); + } + + if let Some(ref op_id) = operation.operation_id { + if let Some(&(prev_method, prev_path)) = seen_operation_ids.get(op_id.as_str()) { + return Err(Diagnostic::policy_violation( + reporter, + "duplicate-operation-id", + format!( + "Failed to plan services: operationId '{}' is defined on both {} {} and {} {}. \ + operationIds must be globally unique.", + op_id, + prev_method.to_ascii_uppercase(), + prev_path, + method.to_ascii_uppercase(), + path, + ), + )); + } + seen_operation_ids.insert(op_id.as_str(), (method, path.as_str())); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, rc::Rc}; + + use crate::{parse::input::decode_openapi_input, test_support::test_ctx}; + + use super::{validate_generation_policy, validate_openapi_version}; + + fn decode(json: &str) -> crate::parse::openapi_model::OpenApiDocument { + let display: Rc = Rc::from("fixture.json"); + decode_openapi_input(Path::new("fixture.json"), json, &display).expect("decode should succeed") + } + + #[test] + fn validate_openapi_version_accepts_documents_without_operation_id() { + let document = decode( + r#"{"openapi":"3.0.3","info":{"title":"Missing OperationId","version":"1.0.0"}, + "paths":{"/pets":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, + ); + + let mut ctx = test_ctx(); + validate_openapi_version(&document, &ctx.reporter()).expect("version check should pass"); + } + + #[test] + fn validate_openapi_version_rejects_non_3x_openapi_version() { + let document = + decode(r#"{"openapi":"2.0.0","info":{"title":"Old","version":"1.0.0"},"paths":{}}"#); + + let mut ctx = test_ctx(); + let Err(error) = validate_openapi_version(&document, &ctx.reporter()) else { + panic!("old version should fail") + }; + + assert_eq!( + error.code, + crate::error::DiagnosticCode::UnsupportedSemantic + ); + assert!(error.message.contains("3.x")); + } + + #[test] + fn validate_generation_policy_rejects_missing_operation_id() { + let document = decode( + r#"{"openapi":"3.0.3","info":{"title":"Missing OperationId","version":"1.0.0"}, + "paths":{"/pets":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, + ); + let mut ctx = test_ctx(); + + let Err(error) = validate_generation_policy(&document, &ctx.reporter()) else { + panic!("missing operationId should fail") + }; + + assert_eq!(error.code, crate::error::DiagnosticCode::PolicyViolation); + assert_eq!(error.subcode, Some("missing-operation-id")); + assert!( + error + .message + .contains("must define operationId when service generation is enabled") + ); + } + + #[test] + fn validate_generation_policy_accepts_operations_with_operation_ids() { + let document = decode( + r#"{"openapi":"3.0.3","info":{"title":"Has OperationId","version":"1.0.0"}, + "paths":{"/pets":{"get":{"operationId":"listPets","responses":{"200":{"description":"ok"}}}}}}"#, + ); + let mut ctx = test_ctx(); + + validate_generation_policy(&document, &ctx.reporter()) + .expect("operation with operationId should pass"); + } + + #[test] + fn duplicate_operation_id_is_rejected() { + let yaml = include_str!("../../test/fixtures/duplicate-operation-id.openapi.yaml"); + let display: Rc = Rc::from("fixture.yaml"); + let document = decode_openapi_input(Path::new("fixture.yaml"), yaml, &display) + .expect("decode should succeed"); + let mut ctx = test_ctx(); + let err = validate_generation_policy(&document, &ctx.reporter()) + .expect_err("should reject duplicate operationId"); + assert_eq!(err.code, crate::error::DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("duplicate-operation-id")); + } +} + +#[cfg(test)] +mod cap_tests { + use std::rc::Rc; + + use crate::{ + parse::input::{ + DEFAULT_MAX_OPERATIONS, DEFAULT_MAX_SCHEMAS, max_operations_from, max_schemas_from, + }, + test_support::test_ctx, + }; + + use super::validate_generation_policy; + + // Pure-function tests for the cap helpers — not affected by OnceLock state. + + #[test] + fn schemas_cap_helper_default() { + assert_eq!(max_schemas_from(None), DEFAULT_MAX_SCHEMAS); + assert_eq!(max_schemas_from(None), 10_000); + } + + #[test] + fn operations_cap_helper_default() { + assert_eq!(max_operations_from(None), DEFAULT_MAX_OPERATIONS); + assert_eq!(max_operations_from(None), 10_000); + } + + #[test] + fn schemas_cap_helper_respects_valid_env() { + assert_eq!(max_schemas_from(Some("1")), 1); + assert_eq!(max_schemas_from(Some("0")), 0); + } + + #[test] + fn operations_cap_helper_respects_valid_env() { + assert_eq!(max_operations_from(Some("1")), 1); + assert_eq!(max_operations_from(Some("0")), 0); + } + + #[test] + fn schemas_cap_helper_rejects_invalid_env_uses_default() { + assert_eq!(max_schemas_from(Some("not-a-number")), DEFAULT_MAX_SCHEMAS); + assert_eq!(max_schemas_from(Some("")), DEFAULT_MAX_SCHEMAS); + assert_eq!(max_schemas_from(Some("-1")), DEFAULT_MAX_SCHEMAS); + } + + #[test] + fn operations_cap_helper_rejects_invalid_env_uses_default() { + assert_eq!( + max_operations_from(Some("not-a-number")), + DEFAULT_MAX_OPERATIONS + ); + assert_eq!(max_operations_from(Some("")), DEFAULT_MAX_OPERATIONS); + assert_eq!(max_operations_from(Some("-1")), DEFAULT_MAX_OPERATIONS); + } + + // Build an OpenAPI YAML document on the fly with N empty-object schemas + // under components.schemas. Used to assert the schema-cap fires at the + // configured boundary. + fn build_doc_with_schemas(n: usize) -> String { + let mut s = String::from( + "openapi: 3.0.3\ninfo:\n title: Bulk\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n", + ); + for i in 0..n { + s.push_str(&format!(" S{i}:\n type: object\n")); + } + s + } + + // Build an OpenAPI YAML document with N total operations distributed + // across paths (up to 8 operations per path, alphabetical methods). + fn build_doc_with_operations(n: usize) -> String { + let methods = [ + "delete", "get", "head", "options", "patch", "post", "put", "trace", + ]; + let mut s = String::from("openapi: 3.0.3\ninfo:\n title: Bulk\n version: 1.0.0\npaths:\n"); + let mut remaining = n; + let mut path_idx = 0usize; + while remaining > 0 { + s.push_str(&format!(" /p{path_idx}:\n")); + let chunk = remaining.min(methods.len()); + for (mi, method) in methods.iter().take(chunk).enumerate() { + let op_id = format!("op_{path_idx}_{mi}"); + s.push_str(&format!( + " {method}:\n operationId: {op_id}\n tags: [t]\n responses:\n '200':\n description: ok\n", + )); + } + remaining -= chunk; + path_idx += 1; + } + s + } + + fn decode(yaml: &str) -> crate::parse::openapi_model::OpenApiDocument { + let display: Rc = Rc::from("fixture.yaml"); + crate::parse::input::decode_openapi_input(std::path::Path::new("fixture.yaml"), yaml, &display) + .expect("decode should succeed") + } + + #[test] + fn schemas_cap_rejects_oversize() { + let yaml = build_doc_with_schemas(DEFAULT_MAX_SCHEMAS + 1); + let document = decode(&yaml); + + let mut ctx = test_ctx(); + let err = validate_generation_policy(&document, &ctx.reporter()) + .expect_err("should reject oversize schemas"); + + assert_eq!(err.code, crate::error::DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("schema-cap-exceeded")); + assert!( + err.message.contains("OPENAPI_NG_MAX_SCHEMAS"), + "expected env-var hint in message: {}", + err.message, + ); + } + + #[test] + fn operations_cap_rejects_oversize() { + let yaml = build_doc_with_operations(DEFAULT_MAX_OPERATIONS + 1); + let document = decode(&yaml); + + let mut ctx = test_ctx(); + let err = validate_generation_policy(&document, &ctx.reporter()) + .expect_err("should reject oversize operations"); + + assert_eq!(err.code, crate::error::DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("operation-cap-exceeded")); + assert!( + err.message.contains("OPENAPI_NG_MAX_OPERATIONS"), + "expected env-var hint in message: {}", + err.message, + ); + } +} diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 0000000..233032a --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,500 @@ +use std::rc::Rc; + +use crate::{ + bindings::EmitTarget, + emit::{ + MODEL_ARTIFACT_PATH, + angular::{ + REST_MODEL_PATH, REST_MODEL_TEMPLATE, REST_UTIL_PATH, REST_UTIL_TEMPLATE, emit_service, + }, + model::emit_ts_models::emit_model, + render_generated_banner, + }, + error::{Diagnostic, Reporter}, + ir::canonical::ApiModel, + options::{GenerateConfig, validate_generate_config}, + plan::plan_generation, + result::{GenerateSummary, GeneratedArtifact}, +}; + +// ── Result types ──────────────────────────────────────────────────────────── + +pub struct GenerateResult { + pub summary: GenerateSummary, + pub diagnostics: Vec, + pub artifacts: Vec, +} + +/// Top-level pipeline outcome on failure: the accumulated warnings up to the +/// failure point, plus the fatal diagnostic that ended the pipeline. Warnings +/// "ride on the reporter" inside stages; this struct exists only at the +/// pipeline boundary so the NAPI layer can surface both halves to the +/// consumer. +#[derive(Debug)] +pub struct GenerateFailure { + pub warnings: Vec, + pub fatal: Diagnostic, +} + +// ── Pipeline ──────────────────────────────────────────────────────────────── + +/// Decode → policy-check → normalize. `normalize_api_model` performs +/// the final semantic step (discriminator narrowing + `$ref` +/// validation) before returning. +pub(crate) fn build_ir( + config: &GenerateConfig, + display_path: &Rc, + reporter: &mut Reporter<'_>, +) -> Result { + let document = match (&config.input_path, &config.input_contents) { + (Some(path), None) => crate::parse::read_and_decode(path, display_path)?, + (None, Some(contents)) => { + crate::parse::decode_input_contents(contents, config.input_format, display_path)? + } + // Validator guarantees exactly-one — these branches are unreachable + // in practice but we keep them defensive rather than panicking. + _ => { + return Err(Diagnostic::new( + crate::error::DiagnosticCode::InvalidOption, + "internal: pipeline reached build_ir with invalid input config", + Rc::clone(display_path), + )); + } + }; + crate::parse::validate_openapi_version(&document, reporter)?; + crate::parse::validate_generation_policy(&document, reporter)?; + crate::ir::normalize_api_model(&document, &config.response_type_mapping, reporter) +} + +pub fn execute_generate(config: GenerateConfig) -> Result { + // Self-test hook for `catch_unwind` at the NAPI boundary. The magic + // input-path string is opaque enough that no real spec path can hit it; + // kept in release builds so CI exercises the panic-to-E_UNEXPECTED path. + if config.input_path.as_deref() == Some("__panic_for_test__") { + panic!("test sentinel: forced panic"); + } + + // Build display_path: honour an explicitly-supplied value (URL inputs, + // direct inputContents callers); otherwise derive from input_path with + // backslash-to-slash normalisation. + let display_path: Rc = config.display_path.as_deref().map_or_else( + || { + config.input_path.as_deref().map_or_else( + || Rc::from(""), + |path| { + Rc::from( + std::path::Path::new(path) + .to_string_lossy() + .replace('\\', "/"), + ) + }, + ) + }, + Rc::from, + ); + + let mut warnings: Vec = Vec::new(); + + match run_pipeline(config, Rc::clone(&display_path), &mut warnings) { + Ok((summary, artifacts)) => Ok(GenerateResult { + summary, + diagnostics: warnings, + artifacts, + }), + Err(fatal) => Err(GenerateFailure { warnings, fatal }), + } +} + +fn run_pipeline( + mut config: GenerateConfig, + display_path: Rc, + warnings: &mut Vec, +) -> Result<(GenerateSummary, Vec), Diagnostic> { + let mut reporter = Reporter::new(Rc::clone(&display_path), warnings); + validate_generate_config(&mut config, &mut reporter)?; + let ir = build_ir(&config, &display_path, &mut reporter)?; + let summary = GenerateSummary::from_ir(display_path.as_ref().to_string(), &ir); + let source_path = summary.normalized_source_path.as_str(); + + let plan = plan_generation(&config, &ir, &reporter)?; + // One banner allocation per pipeline run, threaded into every emitter + // by reference. Bench-large emits 35+ artifacts; this trims one + // `format!` per artifact (and on petstore-sized inputs the cost is + // also paid by every consumer test). + let banner = render_generated_banner(source_path); + + // Canonical emit order: models → angular-rest support → per-tag + // services. `plan.services` is already class-name-sorted by + // `resolve_service_plans`, so artifact ordering is independent of + // operation insertion order. + let mut artifacts: Vec = Vec::new(); + if config.emit.contains(&EmitTarget::Models) && !ir.schemas.is_empty() { + let body = emit_model(&ir.schemas, &plan.mapped_types); + artifacts.push(GeneratedArtifact::new( + MODEL_ARTIFACT_PATH.to_string(), + format!("{banner}{body}"), + )); + } + if config.emit.contains(&EmitTarget::Angular) { + artifacts.push(GeneratedArtifact::new( + REST_MODEL_PATH.to_string(), + format!("{banner}{REST_MODEL_TEMPLATE}"), + )); + artifacts.push(GeneratedArtifact::new( + REST_UTIL_PATH.to_string(), + format!("{banner}{REST_UTIL_TEMPLATE}"), + )); + for service in &plan.services { + let body = emit_service(service); + artifacts.push(GeneratedArtifact::new( + service.artifact_path.clone(), + format!("{banner}{body}"), + )); + } + } + + crate::io::writer::write_generated_artifacts( + config.output_path.as_deref(), + &artifacts, + &reporter, + )?; + + Ok((summary, artifacts)) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::Path, + rc::Rc, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::{ + bindings::EmitTarget, + error::{Diagnostic, DiagnosticCode}, + options::GenerateConfig, + parse::input::decode_openapi_input, + result::{GenerateSummary, GeneratedArtifact}, + test_support::test_ctx, + }; + + use super::{GenerateResult, build_ir, execute_generate}; + + // ── build_ir ───────────────────────────────────────────────────────────── + + fn build_ir_config_for_path(path: &str) -> GenerateConfig { + GenerateConfig { + input_path: Some(path.to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: None, + emit: [EmitTarget::Models].into_iter().collect(), + mapped_types: Vec::new(), + response_type_mapping: Vec::new(), + naming_options: None, + naming: crate::plan::naming::NamingConfig::default(), + } + } + + #[test] + fn build_ir_runs_input_validation_policy_and_normalize_in_one_pass() { + let mut ctx = test_ctx(); + let display: Rc = Rc::from("test/fixtures/petstore-minimal.openapi.yaml"); + let config = build_ir_config_for_path("test/fixtures/petstore-minimal.openapi.yaml"); + let ir = build_ir(&config, &display, &mut ctx.reporter()).expect("compiler stages succeed"); + + assert_eq!(ir.info.title, "Petstore Minimal"); + assert_eq!(ir.info.spec_version, "3.0.3"); + assert_eq!(ir.schemas.len(), 1); + assert_eq!(ir.operations.len(), 1); + assert_eq!(ir.operations[0].operation_id, "listPets"); + } + + #[test] + fn build_ir_rejects_malformed_operation_at_decode() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock works") + .as_nanos(); + let path = + std::env::temp_dir().join(format!("openapi-ng-invalid-operation-shape-{nanos}.json")); + fs::write( + &path, + serde_json::json!({ + "openapi": "3.0.3", + "info": { "title": "Invalid Operation Shape", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": [] + } + } + }) + .to_string(), + ) + .expect("fixture should be written"); + + let mut ctx = test_ctx(); + let path_str = path.to_str().expect("utf-8 path"); + let display: Rc = Rc::from(path_str); + let config = build_ir_config_for_path(path_str); + let Err(failure) = build_ir(&config, &display, &mut ctx.reporter()) else { + panic!("invalid operation shape should fail") + }; + + assert_eq!(failure.code, DiagnosticCode::InputInvalid); + + let _ = fs::remove_file(path); + } + + #[test] + fn decode_rejects_malformed_document_structure() { + let display: Rc = Rc::from("fixture.json"); + let error = decode_openapi_input( + Path::new("fixture.json"), + r#"{"openapi":"3.0.3","info":{"title":"Broken","version":"1.0.0"},"paths":{},"components":{"schemas":[]}}"#, + &display, + ) + .expect_err("schemas as array should fail at decode"); + + assert_eq!(error.code, DiagnosticCode::InputInvalid); + } + + fn test_summary() -> GenerateSummary { + GenerateSummary { + normalized_source_path: "test/fixtures/petstore-minimal.openapi.yaml".to_string(), + spec_version: "3.0.3".to_string(), + title: "Petstore Minimal".to_string(), + path_count: 1, + operation_count: 1, + schema_count: 1, + } + } + + // ── GenerateResult ─────────────────────────────────────────────────────── + + #[test] + fn generated_artifact_new_preserves_path_and_contents() { + let artifact = GeneratedArtifact::new( + "rest/pet.rest.generated.ts".to_string(), + "zażółć".to_string(), + ); + + assert_eq!(artifact.path, "rest/pet.rest.generated.ts"); + assert_eq!(artifact.contents, "zażółć"); + } + + #[test] + fn generate_result_success_builds_the_frozen_success_shape() { + let diagnostic = Diagnostic::new( + DiagnosticCode::UnsupportedSemantic, + "Example warning", + std::rc::Rc::from("spec.yaml"), + ); + let artifact = GeneratedArtifact::new( + "model.generated.ts".to_string(), + "export interface Pet {}\n".to_string(), + ); + + let result = GenerateResult { + summary: test_summary(), + diagnostics: vec![diagnostic.clone()], + artifacts: vec![artifact.clone()], + }; + + assert_eq!(result.summary, test_summary()); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].code, diagnostic.code); + assert_eq!(result.diagnostics[0].message, diagnostic.message); + assert_eq!(result.artifacts, vec![artifact]); + } + + // ── execute_generate ───────────────────────────────────────────────────── + + #[test] + fn execute_generate_emits_typescript_and_angular_artifacts_in_canonical_order() { + let result = execute_generate(GenerateConfig { + input_path: Some("test/fixtures/petstore-rich.openapi.yaml".to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: None, + emit: [EmitTarget::Models, EmitTarget::Angular] + .into_iter() + .collect(), + mapped_types: Vec::new(), + response_type_mapping: Vec::new(), + naming_options: None, + naming: crate::plan::naming::NamingConfig::default(), + }) + .expect("generation succeeds"); + + assert_eq!(result.summary.title, "Petstore Rich"); + assert_eq!( + result + .artifacts + .iter() + .map(|artifact| artifact.path.as_str()) + .collect::>(), + vec![ + "model.generated.ts", + "rest.model.ts", + "rest.util.ts", + "rest/pet.rest.generated.ts", + ] + ); + } + + #[test] + fn execute_generate_dispatches_support_artifact_template() { + let result = execute_generate(GenerateConfig { + input_path: Some("test/fixtures/petstore-rich.openapi.yaml".to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: None, + emit: [EmitTarget::Models, EmitTarget::Angular] + .into_iter() + .collect(), + mapped_types: Vec::new(), + response_type_mapping: Vec::new(), + naming_options: None, + naming: crate::plan::naming::NamingConfig::default(), + }) + .expect("generation succeeds"); + + let util_artifact = result + .artifacts + .iter() + .find(|a| a.path == "rest.util.ts") + .expect("rest.util.ts present"); + assert_eq!(util_artifact.path, "rest.util.ts"); + assert!( + util_artifact + .contents + .contains("export const requestFactory") + ); + } + + #[test] + fn execute_generate_inlines_error_interface_into_service_file_when_operation_has_errors() { + let result = execute_generate(GenerateConfig { + input_path: Some("test/fixtures/errors-typed.openapi.yaml".to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: None, + emit: [EmitTarget::Models, EmitTarget::Angular] + .into_iter() + .collect(), + mapped_types: Vec::new(), + response_type_mapping: Vec::new(), + naming_options: None, + naming: crate::plan::naming::NamingConfig::default(), + }) + .expect("generation succeeds"); + + // The artifact list has no `errors.generated.ts` — error interfaces + // live alongside `*Params` inside the per-tag service file. + assert!( + !result + .artifacts + .iter() + .any(|a| a.path == "errors.generated.ts"), + "errors.generated.ts must not be emitted as a standalone artifact", + ); + + let service = result + .artifacts + .iter() + .find(|a| a.path == "rest/pet.rest.generated.ts") + .expect("pet service emitted"); + + // Per-status pairs render verbatim; numeric keys; refs to model types + // resolve through the existing model import (no extra import block). + assert!(service.contents.contains("export interface UpdatePetError")); + assert!(service.contents.contains("400: ValidationProblem;")); + assert!(service.contents.contains("404: NotFound;")); + assert!(service.contents.contains("500: {")); + assert!(service.contents.contains("traceId: string;")); + // 503 declared no JSON content — silently skipped. + assert!(!service.contents.contains("503:")); + // `default` key intentionally not surfaced. + assert!(!service.contents.contains("default:")); + // The same model import that already serves `*Params` also covers + // the error body refs. The nested `body: UpdatePetRequest` field + // contributes that ref, so the deduplicated, alphabetised import + // line carries it alongside the response type (`Pet`) and the + // error-body refs. + assert!( + service + .contents + .contains("import type { NotFound, Pet, UpdatePetRequest, ValidationProblem }"), + ); + } + + #[test] + fn execute_generate_runs_pipeline_with_input_contents_and_explicit_display_path() { + let yaml = "openapi: 3.0.3\n\ + info: { title: Inline Test, version: 1.0.0 }\n\ + paths: {}\n"; + let config = GenerateConfig { + input_path: None, + input_contents: Some(yaml.to_string()), + display_path: Some("https://example.com/spec.yaml".to_string()), + input_format: Some(crate::bindings::InputFormat::Yaml), + output_path: None, + emit: [crate::bindings::EmitTarget::Models].into_iter().collect(), + mapped_types: Vec::new(), + response_type_mapping: Vec::new(), + naming_options: None, + naming: crate::plan::naming::NamingConfig::default(), + }; + let result = execute_generate(config).expect("inputContents pipeline must succeed"); + assert_eq!(result.summary.title, "Inline Test"); + // display_path is the supplied URL verbatim — no slash-normalisation, + // no path resolution. + assert_eq!( + result.summary.normalized_source_path, + "https://example.com/spec.yaml", + ); + } + + #[test] + fn execute_generate_executes_generation_through_application_boundary() { + let result = execute_generate(GenerateConfig { + input_path: Some("test/fixtures/petstore-minimal.openapi.yaml".to_string()), + input_contents: None, + display_path: None, + input_format: None, + output_path: None, + emit: [EmitTarget::Models, EmitTarget::Angular] + .into_iter() + .collect(), + mapped_types: Vec::new(), + response_type_mapping: Vec::new(), + naming_options: None, + naming: crate::plan::naming::NamingConfig::default(), + }) + .expect("generation succeeds"); + + assert_eq!(result.summary.title, "Petstore Minimal"); + assert_eq!( + result + .artifacts + .iter() + .map(|artifact| artifact.path.as_str()) + .collect::>(), + vec![ + "model.generated.ts", + "rest.model.ts", + "rest.util.ts", + "rest/pet.rest.generated.ts", + ] + ); + } +} diff --git a/src/plan/artifact_plan.rs b/src/plan/artifact_plan.rs new file mode 100644 index 0000000..8a112cf --- /dev/null +++ b/src/plan/artifact_plan.rs @@ -0,0 +1,732 @@ +use std::collections::BTreeMap; + +use crate::{ + error::{Diagnostic, DiagnosticCode, Reporter}, + ir::canonical::{ + ApiModel, BodyFieldType, ErrorResponse, HttpMethod, ModelSymbol, ResponseContent, + }, + ir::schema::SchemaType, + options::MappedType, +}; + +use super::{ + naming::{service_class_name, service_file_stem}, + services::plan_request_contract, +}; + +/// `MappedType` after schema-name validation. The `schema` field borrows +/// from the IR's model symbol that was matched, encoding the validated +/// lifecycle in the type system: callers receive `ResolvedMappedType` +/// only after `validate_mapped_types_against_schemas` confirmed the +/// schema exists. `import`, `ty`, and `alias` are owned `Box` +/// (cloned from the input `MappedType`) since they are short identifier +/// strings consumed by emit. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResolvedMappedType<'a> { + pub(crate) schema: &'a str, + pub(crate) import: Box, + pub(crate) ty: Box, + pub(crate) alias: Option>, +} + +impl<'a> ResolvedMappedType<'a> { + pub(crate) fn new(schema: &'a str, source: &MappedType) -> Self { + Self { + schema, + import: Box::from(source.import.as_str()), + ty: Box::from(source.ty.as_str()), + alias: source.alias.as_deref().map(Box::from), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ServicePlan<'ir> { + pub(crate) group_name: String, + pub(crate) class_name: String, + pub(crate) artifact_path: String, + pub(crate) operations: Vec>, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct PlannedOperation<'ir> { + pub(crate) operation_id: String, + pub(crate) method_name: String, + pub(crate) method: HttpMethod, + pub(crate) path: String, + pub(crate) request: PlannedRequestContract<'ir>, + pub(crate) response: Option<&'ir ResponseContent>, + /// Borrowed from the IR's `OperationDef.errors`. Empty when the + /// operation declared no 4xx/5xx response with a JSON schema. The + /// angular emit walks this to render a `{Pascal}Error` interface + /// alongside the operation's `{Pascal}Params`. + pub(crate) errors: &'ir [ErrorResponse], + pub(crate) description: Option, + pub(crate) deprecated: bool, +} + +/// Per-field discriminator for `PlannedRequestField` that tells emit code +/// which slot of the HTTP request a field maps to. Headers live on +/// `PlannedRequestContract.headers` and the request body lives on +/// `PlannedRequestContract.body`; `Body` here marks the body properties +/// hoisted into top-level fields by the smart-flatten rule (inline JSON +/// object bodies). Nested-body operations carry no `Body`-kinded entries +/// — their body sits on the dedicated slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RequestFieldKind { + Path, + Query, + Body, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct PlannedRequestContract<'ir> { + /// Path and query parameters. Inline-JSON-body properties are not stored + /// here — they live inside `PlannedRequestBody::FlatJson` so emit can + /// dispatch on the body kind without filtering by `RequestFieldKind`. + pub(crate) fields: Vec>, + /// Header parameters surfaced on the request interface as a nested + /// `headers: { ... }` field. Empty when the operation declares no + /// `in: header` parameters. + pub(crate) headers: Vec>, + /// The request body's planned layout. `None` when the operation + /// declares no body. + pub(crate) body: Option>, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct PlannedRequestField<'ir> { + pub(crate) name: Box, + pub(crate) optional: bool, + pub(crate) ty: &'ir SchemaType, + pub(crate) kind: RequestFieldKind, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct PlannedHeader<'ir> { + pub(crate) name: Box, + pub(crate) optional: bool, + pub(crate) ty: &'ir SchemaType, +} + +/// A single form-body field for multipart/form-data or +/// application/x-www-form-urlencoded request bodies. Borrows the +/// `BodyFieldType` from the IR; the emit type-printer dispatches on that +/// enum to render the right TS type (string / Blob / number[] / Blob[] …). +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct PlannedFormField<'ir> { + pub(crate) name: Box, + pub(crate) optional: bool, + pub(crate) ty: &'ir BodyFieldType, +} + +/// The request body's planned layout. The smart-flatten rule splits JSON +/// bodies in two: a top-level `$ref` (or any non-object schema) stays +/// `Nested`, preserving the spec author's named type as `body: T` on the +/// request interface; an inline `type: object` body becomes `FlatJson`, +/// hoisting its properties to top-level fields beside path/query. Form +/// bodies always flatten — their `BodyFieldType`-typed fields can't +/// compose back under the source schema name anyway. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum PlannedRequestBody<'ir> { + /// Renders as a nested `body: T` field on the request interface and + /// forwards verbatim via shorthand from the builder. Produced for JSON + /// bodies whose schema is a top-level `$ref` or any non-object shape + /// (scalar, array, union) where there is no property structure to + /// hoist. + Nested { ty: &'ir SchemaType, optional: bool }, + /// Body was an inline JSON object; its properties are hoisted as + /// `RequestFieldKind::Body` entries on this variant. Each property's + /// `optional` already accounts for the body envelope's `required` + /// flag (an `required: false` body downgrades every property to + /// optional regardless of its individual schema flag). + FlatJson { + properties: Vec>, + required: bool, + }, + /// `multipart/form-data` body. Fields render as top-level entries on + /// the request interface (typed via `BodyFieldType`); builder + /// materializes them into a `FormData` at runtime. + Multipart { fields: Vec> }, + /// `application/x-www-form-urlencoded` body. Fields render as + /// top-level entries on the request interface; builder materializes + /// them into `URLSearchParams`. + UrlEncoded { fields: Vec> }, +} + +/// Verifies that each `mapped_types[].schema` resolves to a top-level +/// model symbol and returns a `Vec>` borrowing +/// the matched symbol names from the IR. Pre-emit gate so a typo +/// doesn't silently produce an emit that omits the placeholder for the +/// missing schema. The return type encodes the validated lifecycle: +/// `MappedType` is user input, `ResolvedMappedType` is what emit consumes. +pub(crate) fn validate_mapped_types_against_schemas<'ir>( + model_symbols: &'ir [ModelSymbol], + mapped_types: &[MappedType], + reporter: &Reporter<'_>, +) -> Result>, Diagnostic> { + let by_name = model_symbols + .iter() + .map(|symbol| (symbol.name.as_ref(), symbol)) + .collect::>(); + + let mut resolved = Vec::with_capacity(mapped_types.len()); + for mapped_type in mapped_types { + let symbol = by_name.get(mapped_type.schema.as_str()).ok_or_else(|| { + reporter.error( + DiagnosticCode::InvalidOption, + format!( + "Failed to resolve generation options: mapped schema {} does not exist in the IR.", + mapped_type.schema + ), + ) + })?; + resolved.push(ResolvedMappedType::new(symbol.name.as_ref(), mapped_type)); + } + + Ok(resolved) +} + +pub(crate) fn resolve_service_plans<'ir>( + ir: &'ir ApiModel, + resolver: &crate::plan::naming::NamingResolver, + reporter: &Reporter<'_>, +) -> Result>, Diagnostic> { + use super::services::group_operations; + + let grouped_operations = group_operations(&ir.operations, resolver, reporter)?; + let mut services = Vec::with_capacity(grouped_operations.len()); + for (group_name, group_operations) in grouped_operations { + let mut operations: Vec> = group_operations + .iter() + .map(|(operation, method_name)| { + Ok(PlannedOperation { + operation_id: operation.operation_id.clone(), + method_name: method_name.clone(), + method: operation.method, + path: operation.path.clone(), + request: plan_request_contract(operation, reporter)?, + response: operation.response.as_ref(), + errors: operation.errors.as_slice(), + description: operation.description.clone(), + deprecated: operation.deprecated, + }) + }) + .collect::, Diagnostic>>()?; + operations.sort_by(|a, b| a.method_name.cmp(&b.method_name)); + + let artifact_path = format!("rest/{}.rest.generated.ts", service_file_stem(&group_name)); + + services.push(ServicePlan { + group_name: group_name.clone(), + class_name: service_class_name(&group_name), + artifact_path, + operations, + }); + } + services.sort_by(|a, b| a.class_name.cmp(&b.class_name)); + + Ok(services) +} + +#[cfg(test)] +mod tests { + use super::{ + PlannedFormField, PlannedRequestBody, PlannedRequestContract, RequestFieldKind, + resolve_service_plans, validate_mapped_types_against_schemas, + }; + use crate::{ + ir::{ + canonical::{ + ApiInfo, ApiModel, BodyContent, BodyFieldType, HttpMethod, ModelSymbol, OperationDef, + RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, ResponseContent, + }, + schema::{SchemaProperty, SchemaScalar, SchemaType}, + }, + options::MappedType, + }; + + use crate::test_support::test_ctx; + + fn api_model(schemas: Vec, operations: Vec) -> ApiModel { + ApiModel { + info: ApiInfo { + spec_version: "3.0.3".to_string(), + title: "Test".to_string(), + }, + schemas, + operations, + } + } + + fn test_model_symbols() -> Vec { + vec![ + ModelSymbol { + name: "UserId".into(), + description: None, + deprecated: false, + body: SchemaType::Ref("string".into()), + }, + ModelSymbol { + name: "User".into(), + description: None, + deprecated: false, + body: SchemaType::InlineObject { + properties: Vec::new(), + }, + }, + ] + } + + fn service_test_ir() -> ApiModel { + let model_symbols = vec![ + ModelSymbol { + name: "PetId".into(), + description: None, + deprecated: false, + body: SchemaType::Scalar(SchemaScalar::String), + }, + ModelSymbol { + name: "PetStatus".into(), + description: None, + deprecated: false, + body: SchemaType::StringLiterals { + values: vec!["available".to_string(), "pending".to_string()], + }, + }, + ModelSymbol { + name: "UpdatePetPayload".into(), + description: None, + deprecated: false, + body: SchemaType::InlineObject { + properties: vec![ + SchemaProperty { + name: "status".into(), + required: true, + ty: SchemaType::Ref("PetStatus".into()), + description: None, + deprecated: false, + }, + SchemaProperty { + name: "tagIds".into(), + required: true, + ty: SchemaType::Array(Box::new(SchemaType::Scalar(SchemaScalar::Number))), + description: None, + deprecated: false, + }, + SchemaProperty { + name: "nickname".into(), + required: false, + ty: SchemaType::Nullable(Box::new(SchemaType::Scalar(SchemaScalar::String))), + description: None, + deprecated: false, + }, + ], + }, + }, + ModelSymbol { + name: "Pet".into(), + description: None, + deprecated: false, + body: SchemaType::InlineObject { + properties: Vec::new(), + }, + }, + ModelSymbol { + name: "PetList".into(), + description: None, + deprecated: false, + body: SchemaType::InlineObject { + properties: Vec::new(), + }, + }, + ]; + let operations = vec![ + OperationDef { + operation_id: "listPets".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Get, + path: "/pets".to_string(), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Ref( + "PetList".into(), + )))), + errors: Vec::new(), + description: None, + deprecated: false, + }, + OperationDef { + operation_id: "updatePet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![ + RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }, + RequestInputDef { + name: "includeHistory".into(), + source: RequestInputSource::Query, + required: false, + ty: SchemaType::Scalar(SchemaScalar::Boolean), + }, + ], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("UpdatePetPayload".into())), + }), + }, + response: Some(ResponseContent::Json(Some(SchemaType::Ref("Pet".into())))), + errors: Vec::new(), + description: None, + deprecated: false, + }, + OperationDef { + operation_id: "createAdoptionRequest".to_string(), + tags: vec!["AdoptionRequest".to_string()], + method: HttpMethod::Post, + path: "/adoption-requests".to_string(), + request: RequestDef { + inputs: Vec::new(), + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "petId".into(), + required: true, + ty: SchemaType::Ref("PetId".into()), + description: None, + deprecated: false, + }], + }), + }), + }, + response: Some(ResponseContent::Json(Some(SchemaType::Ref("Pet".into())))), + errors: Vec::new(), + description: None, + deprecated: false, + }, + ]; + api_model(model_symbols, operations) + } + + #[test] + fn model_symbol_name_returns_variant_name() { + let symbols = test_model_symbols(); + + assert_eq!( + symbols + .iter() + .map(|symbol| symbol.name.as_ref()) + .collect::>(), + vec!["UserId", "User"] + ); + } + + #[test] + fn validate_mapped_types_accepts_schemas_that_exist_in_the_ir() { + let mut ctx = test_ctx(); + let symbols = test_model_symbols(); + let resolved = validate_mapped_types_against_schemas( + &symbols, + &[MappedType { + schema: "UserId".to_string(), + import: "./shared/user-id".to_string(), + ty: "ExternalUserId".to_string(), + alias: Some("UserId".to_string()), + }], + &ctx.reporter(), + ) + .expect("mapped types validate against IR"); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].schema, "UserId"); + assert_eq!(resolved[0].import.as_ref(), "./shared/user-id"); + assert_eq!(resolved[0].ty.as_ref(), "ExternalUserId"); + assert_eq!(resolved[0].alias.as_deref(), Some("UserId")); + } + + #[test] + fn validate_mapped_types_rejects_schemas_missing_from_the_ir() { + let mut ctx = test_ctx(); + let err = validate_mapped_types_against_schemas( + &test_model_symbols(), + &[MappedType { + schema: "Missing".to_string(), + import: "./missing".to_string(), + ty: "Missing".to_string(), + alias: None, + }], + &ctx.reporter(), + ) + .expect_err("missing schema should fail validation"); + + assert_eq!(err.code, crate::error::DiagnosticCode::InvalidOption); + assert!(err.message.contains("Missing")); + } + + #[test] + fn resolve_service_plans_groups_operations_and_builds_request_contracts() { + let ir = service_test_ir(); + let mut ctx = test_ctx(); + let services = resolve_service_plans( + &ir, + &crate::plan::naming::NamingResolver::default(), + &ctx.reporter(), + ) + .expect("service plan resolves"); + + assert_eq!(services.len(), 2); + // Services are sorted alphabetically by class_name (AdoptionRequestRest + // sorts before PetRest), regardless of the discovery order in the spec. + assert_eq!( + services + .iter() + .map(|service| service.group_name.as_str()) + .collect::>(), + vec!["AdoptionRequest", "Pet"] + ); + + let pet_service = &services[1]; + assert_eq!(pet_service.class_name, "PetRest"); + assert_eq!(pet_service.artifact_path, "rest/pet.rest.generated.ts"); + assert_eq!( + pet_service + .operations + .iter() + .map(|operation| operation.operation_id.as_str()) + .collect::>(), + vec!["listPets", "updatePet"] + ); + + let update_pet = &pet_service.operations[1]; + assert!(!update_pet.request.fields.is_empty()); + assert_eq!( + update_pet + .request + .fields + .iter() + .map(|field| field.name.as_ref()) + .collect::>(), + vec!["petId", "includeHistory"] + ); + let kinds: Vec = update_pet + .request + .fields + .iter() + .map(|field| field.kind) + .collect(); + assert_eq!(kinds, vec![RequestFieldKind::Path, RequestFieldKind::Query]); + match &update_pet.request.body { + Some(PlannedRequestBody::Nested { ty, optional }) => { + assert!(!optional, "body marked required in fixture"); + assert!( + matches!(ty, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload"), + "expected body ty to remain the ref, got {ty:?}" + ); + } + other => panic!("expected nested ref body, got {other:?}"), + } + } + + #[test] + fn resolve_service_plans_keeps_ref_bodies_nested_under_smart_flatten() { + // Smart-flatten preserves a body that's authored as a `$ref` even when + // that ref resolves to an `InlineObject` schema — the spec author's + // named type is the signal we honor. + let model_symbols = vec![ + ModelSymbol { + name: "PetId".into(), + description: None, + deprecated: false, + body: SchemaType::Scalar(SchemaScalar::String), + }, + ModelSymbol { + name: "CreatePetRequest".into(), + description: None, + deprecated: false, + body: SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "petId".into(), + required: true, + ty: SchemaType::Ref("PetId".into()), + description: None, + deprecated: false, + }], + }, + }, + ]; + let ir = api_model( + model_symbols, + vec![OperationDef { + operation_id: "createPet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }], + ); + + let mut ctx = test_ctx(); + let services = resolve_service_plans( + &ir, + &crate::plan::naming::NamingResolver::default(), + &ctx.reporter(), + ) + .expect("ref body stays nested even when it resolves to an inline object"); + let create_pet = &services[0].operations[0]; + assert!(matches!( + create_pet.request.body, + Some(PlannedRequestBody::Nested { .. }) + )); + } + + #[test] + fn resolve_service_plans_sorts_services_and_operations_alphabetically() { + let operations = vec![ + OperationDef { + operation_id: "zebraInZoo".to_string(), + tags: vec!["Zoo".to_string()], + method: HttpMethod::Get, + path: "/zoo/zebra".to_string(), + request: RequestDef::default(), + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }, + OperationDef { + operation_id: "adoptPet".to_string(), + tags: vec!["Adoption".to_string()], + method: HttpMethod::Post, + path: "/adoptions".to_string(), + request: RequestDef::default(), + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }, + OperationDef { + operation_id: "antInZoo".to_string(), + tags: vec!["Zoo".to_string()], + method: HttpMethod::Get, + path: "/zoo/ant".to_string(), + request: RequestDef::default(), + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }, + OperationDef { + operation_id: "abandonPet".to_string(), + tags: vec!["Adoption".to_string()], + method: HttpMethod::Post, + path: "/abandonments".to_string(), + request: RequestDef::default(), + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }, + ]; + let ir = api_model(Vec::new(), operations); + + let mut ctx = test_ctx(); + let services = resolve_service_plans( + &ir, + &crate::plan::naming::NamingResolver::default(), + &ctx.reporter(), + ) + .expect("plans resolve"); + + assert_eq!( + services + .iter() + .map(|service| service.class_name.as_str()) + .collect::>(), + vec!["AdoptionRest", "ZooRest"] + ); + + for service in &services { + let ids: Vec<&str> = service + .operations + .iter() + .map(|op| op.operation_id.as_str()) + .collect(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + assert_eq!( + ids, sorted, + "operations must be alphabetical by method_name (== operation_id for already-camelCase ids)" + ); + } + + let adoption = &services[0]; + assert_eq!( + adoption + .operations + .iter() + .map(|op| op.operation_id.as_str()) + .collect::>(), + vec!["abandonPet", "adoptPet"] + ); + } + + #[test] + fn planned_request_body_multipart_carries_form_fields_collection() { + let scalar = BodyFieldType::Scalar(SchemaScalar::String); + let contract = PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(PlannedRequestBody::Multipart { + fields: vec![PlannedFormField { + name: "status".into(), + optional: false, + ty: &scalar, + }], + }), + }; + let Some(PlannedRequestBody::Multipart { fields }) = &contract.body else { + panic!("expected multipart body"); + }; + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].name.as_ref(), "status"); + } + + #[test] + fn planned_request_body_carries_smart_flatten_variants() { + let ty = SchemaType::Scalar(SchemaScalar::String); + let _: PlannedRequestBody<'_> = PlannedRequestBody::Nested { + ty: &ty, + optional: false, + }; + let _: PlannedRequestBody<'_> = PlannedRequestBody::FlatJson { + properties: vec![], + required: true, + }; + let _: PlannedRequestBody<'_> = PlannedRequestBody::Multipart { fields: vec![] }; + let _: PlannedRequestBody<'_> = PlannedRequestBody::UrlEncoded { fields: vec![] }; + } +} diff --git a/src/plan/mod.rs b/src/plan/mod.rs new file mode 100644 index 0000000..894e715 --- /dev/null +++ b/src/plan/mod.rs @@ -0,0 +1,57 @@ +// Planning logic that turns `ApiModel` into emitter-ready service plans +// and validates mapped-type configuration against the IR. + +pub(crate) mod artifact_plan; +pub mod naming; +pub(crate) mod services; + +use crate::{ + bindings::EmitTarget, + error::{Diagnostic, Reporter}, + ir::canonical::ApiModel, + options::GenerateConfig, +}; + +use artifact_plan::{ + ResolvedMappedType, ServicePlan, resolve_service_plans, validate_mapped_types_against_schemas, +}; + +/// Pre-emit plan: the validated mapped-type list shared by the model +/// emitter, plus the per-tag Angular service plans. The pipeline +/// decides which artifacts to emit by inspecting `config.emit` directly; +/// `services` is empty when Angular is not selected. +pub(crate) struct GenerationPlan<'ir> { + pub(crate) mapped_types: Vec>, + pub(crate) services: Vec>, +} + +/// Builds the pre-emit plan from the validated config and IR. All +/// cross-target validation (e.g. `emit_models` gates mapped-type +/// resolution) lives here so the pipeline is a flat sequence of +/// guarded emit calls. +pub(crate) fn plan_generation<'ir>( + config: &GenerateConfig, + ir: &'ir ApiModel, + reporter: &Reporter<'_>, +) -> Result, Diagnostic> { + let emit_models = config.emit.contains(&EmitTarget::Models); + let emit_angular = config.emit.contains(&EmitTarget::Angular); + + let mapped_types = if emit_models && !config.mapped_types.is_empty() { + validate_mapped_types_against_schemas(&ir.schemas, &config.mapped_types, reporter)? + } else { + Vec::new() + }; + + let services = if emit_angular { + let resolver = crate::plan::naming::NamingResolver::new(config.naming.clone()); + resolve_service_plans(ir, &resolver, reporter)? + } else { + Vec::new() + }; + + Ok(GenerationPlan { + mapped_types, + services, + }) +} diff --git a/src/plan/naming/case.rs b/src/plan/naming/case.rs new file mode 100644 index 0000000..2666f43 --- /dev/null +++ b/src/plan/naming/case.rs @@ -0,0 +1,177 @@ +//! Tokenizer + case transformations. Tokens are split on any +//! non-alphanumeric character (covers `_`, `-`, whitespace, punctuation) +//! and on case transitions. A run of consecutive uppercase letters is +//! treated as a single token; downstream cases title-case that token, +//! so `getURLPath` → `getUrlPath` for camelCase. +//! +//! This is the single tokenizer used both by the user-facing `case` rule +//! engine and by the project-fixed legacy helpers (`service_class_name`, +//! `service_file_stem`, `request_interface_name`, `infer_body_field_name`), +//! so all naming-side case conversions agree on edge cases. + +use crate::plan::naming::config::Case; + +pub(crate) fn tokenize(s: &str) -> Vec { + let mut tokens: Vec = Vec::new(); + let mut current = String::new(); + let chars: Vec = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + let ch = chars[i]; + if !ch.is_alphanumeric() { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + i += 1; + continue; + } + // Case transition: lowercase/digit → uppercase starts a new token. + if let Some(prev) = current.chars().last() { + let prev_lower_or_digit = prev.is_ascii_lowercase() || prev.is_ascii_digit(); + if prev_lower_or_digit && ch.is_ascii_uppercase() { + tokens.push(std::mem::take(&mut current)); + current.push(ch); + i += 1; + continue; + } + } + // Uppercase run followed by lowercase: the last uppercase belongs to + // the next token. e.g. "URLPath" → ["URL", "Path"]: when reading + // 'P' we know 'L' was the last upper, and the next char would be + // lower — but we only see the lower one char later. So at lowercase, + // if the previous two chars were upper+upper, peel the trailing + // upper into a new token. + if ch.is_ascii_lowercase() && current.len() >= 2 { + let last_two: Vec = current.chars().rev().take(2).collect(); + if last_two[0].is_ascii_uppercase() && last_two[1].is_ascii_uppercase() { + let peeled = current.pop().unwrap(); + tokens.push(std::mem::take(&mut current)); + current.push(peeled); + } + } + current.push(ch); + i += 1; + } + if !current.is_empty() { + tokens.push(current); + } + tokens +} + +pub(crate) fn apply(s: &str, case: Case) -> String { + let tokens = tokenize(s); + if tokens.is_empty() { + return String::new(); + } + match case { + Case::Camel => { + let mut out = String::new(); + for (i, t) in tokens.iter().enumerate() { + if i == 0 { + out.push_str(&t.to_ascii_lowercase()); + } else { + out.push_str(&title_case(t)); + } + } + out + } + Case::Pascal => tokens.iter().map(|t| title_case(t)).collect(), + Case::Snake => tokens + .iter() + .map(|t| t.to_ascii_lowercase()) + .collect::>() + .join("_"), + Case::Kebab => tokens + .iter() + .map(|t| t.to_ascii_lowercase()) + .collect::>() + .join("-"), + Case::Constant => tokens + .iter() + .map(|t| t.to_ascii_uppercase()) + .collect::>() + .join("_"), + } +} + +fn title_case(t: &str) -> String { + let mut chars = t.chars(); + chars.next().map_or_else(String::new, |first| { + let mut out = String::new(); + out.extend(first.to_uppercase()); + out.push_str(&chars.as_str().to_ascii_lowercase()); + out + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tokenize_splits_on_underscore_hyphen_space() { + assert_eq!(tokenize("get_some_thing"), vec!["get", "some", "thing"]); + assert_eq!(tokenize("get-some-thing"), vec!["get", "some", "thing"]); + assert_eq!(tokenize("get some thing"), vec!["get", "some", "thing"]); + } + + #[test] + fn tokenize_splits_on_any_non_alphanumeric_punctuation() { + assert_eq!(tokenize("get.some/thing"), vec!["get", "some", "thing"]); + assert_eq!(tokenize("get!some@thing"), vec!["get", "some", "thing"]); + assert_eq!(tokenize("a__b---c"), vec!["a", "b", "c"]); + } + + #[test] + fn tokenize_splits_on_camel_case_transition() { + assert_eq!(tokenize("getSomeThing"), vec!["get", "Some", "Thing"]); + } + + #[test] + fn tokenize_treats_consecutive_uppercase_as_single_token() { + // From the spec example. + assert_eq!(tokenize("getURLPath"), vec!["get", "URL", "Path"]); + } + + #[test] + fn tokenize_handles_trailing_uppercase_run() { + assert_eq!(tokenize("parseURL"), vec!["parse", "URL"]); + } + + #[test] + fn tokenize_handles_leading_uppercase_run() { + assert_eq!(tokenize("URLPath"), vec!["URL", "Path"]); + } + + #[test] + fn apply_camel_matches_spec_example_table_row() { + // spec: `get_someThing` → camel → `getSomeThing` + assert_eq!(apply("get_someThing", Case::Camel), "getSomeThing"); + } + + #[test] + fn apply_pascal_matches_spec_example_table_row() { + assert_eq!(apply("get_someThing", Case::Pascal), "GetSomeThing"); + } + + #[test] + fn apply_snake_matches_spec_example_table_row() { + assert_eq!(apply("get_someThing", Case::Snake), "get_some_thing"); + } + + #[test] + fn apply_kebab_matches_spec_example_table_row() { + assert_eq!(apply("get_someThing", Case::Kebab), "get-some-thing"); + } + + #[test] + fn apply_constant_matches_spec_example_table_row() { + assert_eq!(apply("get_someThing", Case::Constant), "GET_SOME_THING"); + } + + #[test] + fn apply_camel_handles_consecutive_uppercase_run_per_spec() { + // spec: `getURLPath` → camelCase → `getUrlPath` + assert_eq!(apply("getURLPath", Case::Camel), "getUrlPath"); + } +} diff --git a/src/plan/naming/config.rs b/src/plan/naming/config.rs new file mode 100644 index 0000000..4fd6d48 --- /dev/null +++ b/src/plan/naming/config.rs @@ -0,0 +1,79 @@ +//! Internal representation of the user-facing `NamingConfig`. The NAPI +//! boundary projects `bindings::NamingOptions` into this shape after +//! flag-validating each parse spec and unwrapping the JS RegExp into +//! `{ source, flags }`. + +use crate::plan::naming::parse_spec::CompiledParseSpec; + +#[derive(Debug, Clone, Default)] +pub struct NamingConfig { + pub(crate) method_name: Option, + pub(crate) group: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum Naming { + Single(RuleEntry), + Chain(Vec), +} + +/// A single entry in a chain — either a bare format-string shorthand or +/// a full `Rule`. The shorthand is equivalent to `Rule { format: +/// Some(s), case: None, .. }`; we keep them distinct so config-time +/// error messages can name the source form precisely. +#[derive(Debug, Clone)] +pub(crate) enum RuleEntry { + Shorthand(String), + Rule(Rule), +} + +#[derive(Debug, Clone)] +pub(crate) struct Rule { + pub(crate) from: Option, + pub(crate) parse: Option, + pub(crate) format: Option, + pub(crate) case: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Case { + Camel, + Pascal, + Snake, + Kebab, + Constant, +} + +impl Case { + pub(crate) fn parse(s: &str) -> Option { + match s { + "camel" => Some(Self::Camel), + "pascal" => Some(Self::Pascal), + "snake" => Some(Self::Snake), + "kebab" => Some(Self::Kebab), + "constant" => Some(Self::Constant), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn case_parses_all_five_spec_values() { + assert_eq!(Case::parse("camel"), Some(Case::Camel)); + assert_eq!(Case::parse("pascal"), Some(Case::Pascal)); + assert_eq!(Case::parse("snake"), Some(Case::Snake)); + assert_eq!(Case::parse("kebab"), Some(Case::Kebab)); + assert_eq!(Case::parse("constant"), Some(Case::Constant)); + } + + #[test] + fn case_parse_rejects_unknown_values() { + assert_eq!(Case::parse("upper"), None); + assert_eq!(Case::parse(""), None); + assert_eq!(Case::parse("Camel"), None); + } +} diff --git a/src/plan/naming/context.rs b/src/plan/naming/context.rs new file mode 100644 index 0000000..d30280d --- /dev/null +++ b/src/plan/naming/context.rs @@ -0,0 +1,174 @@ +//! `OperationContext` — the read-only bag of values a `Rule.from` / +//! `Rule.format` template can reference for one operation. Fields map +//! 1:1 to the spec's "Context fields" table. + +use std::collections::BTreeMap; + +use crate::ir::canonical::OperationDef; + +#[derive(Debug)] +pub(crate) struct OperationContext<'a> { + pub(crate) operation_id: Option<&'a str>, + pub(crate) method: String, // lowercased + pub(crate) path: &'a str, + pub(crate) path_segments: Vec, + pub(crate) tags: &'a [String], + pub(crate) extensions: BTreeMap, // x- → string + // contentType / statusCode are unbound here + // until those carriers exist on OperationDef. +} + +impl<'a> OperationContext<'a> { + pub(crate) fn from_operation(operation: &'a OperationDef) -> Self { + Self { + operation_id: if operation.operation_id.is_empty() { + None + } else { + Some(operation.operation_id.as_str()) + }, + method: operation.method.as_str().to_ascii_lowercase(), + path: operation.path.as_str(), + path_segments: clean_path_segments(operation.path.as_str()), + tags: operation.tags.as_slice(), + // `vendor_extensions` does not yet exist on `OperationDef`; an + // empty map keeps `{x-foo}` references unbound (triggering + // fallback) and the carrier can be plumbed through normalize + // later without touching the engine. + extensions: BTreeMap::new(), + } + } + + /// Lookup by template name. Returns `None` for unbound names — the + /// caller turns that into a rule failure. + pub(crate) fn lookup(&self, name: &str) -> Option { + match name { + "operationId" => self.operation_id.map(str::to_string), + "method" => Some(self.method.clone()), + "path" => Some(self.path.to_string()), + _ if name.starts_with("x-") => self.extensions.get(name).cloned(), + _ => None, + } + } + + /// Lookup with array indexing: `pathSegments[0]`, `tags[-1]`, etc. + /// Negative indexes count from the tail. Out-of-bounds is unbound. + pub(crate) fn lookup_indexed(&self, array_name: &str, index: i32) -> Option { + let slice: Vec<&str> = match array_name { + "pathSegments" => self.path_segments.iter().map(String::as_str).collect(), + "tags" => self.tags.iter().map(String::as_str).collect(), + _ => return None, + }; + resolve_index(slice.len(), index).map(|i| slice[i].to_string()) + } +} + +const fn resolve_index(len: usize, index: i32) -> Option { + if index >= 0 { + let i = index as usize; + if i < len { Some(i) } else { None } + } else { + let from_tail = (-index) as usize; + if from_tail == 0 || from_tail > len { + None + } else { + Some(len - from_tail) + } + } +} + +/// Clean a path per spec: +/// * drop leading empty segment (leading `/`) +/// * drop trailing empty segment (trailing `/`) +/// * unwrap `{name}` → `name` (literal content between braces) +fn clean_path_segments(path: &str) -> Vec { + path + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| { + if s.starts_with('{') && s.ends_with('}') && s.len() >= 2 { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::{ + canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, + schema::{SchemaScalar, SchemaType}, + }; + + fn op(operation_id: &str, method: HttpMethod, path: &str, tags: &[&str]) -> OperationDef { + OperationDef { + operation_id: operation_id.to_string(), + tags: tags.iter().map(|s| s.to_string()).collect(), + method, + path: path.to_string(), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Scalar( + SchemaScalar::Boolean, + )))), + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + #[test] + fn clean_path_segments_drops_leading_and_trailing_slashes() { + assert_eq!( + clean_path_segments("/users/{id}/posts/"), + vec!["users", "id", "posts"] + ); + } + + #[test] + fn clean_path_segments_unwraps_path_params_literally() { + assert_eq!( + clean_path_segments("/api/v1/{resource}"), + vec!["api", "v1", "resource"] + ); + } + + #[test] + fn lookup_returns_operation_id_method_and_path() { + let operation = op("listPets", HttpMethod::Get, "/pets", &["Pet"]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(ctx.lookup("operationId").as_deref(), Some("listPets")); + assert_eq!(ctx.lookup("method").as_deref(), Some("get")); + assert_eq!(ctx.lookup("path").as_deref(), Some("/pets")); + } + + #[test] + fn lookup_returns_none_for_missing_operation_id() { + let operation = op("", HttpMethod::Get, "/pets", &["Pet"]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(ctx.lookup("operationId"), None); + } + + #[test] + fn lookup_indexed_supports_positive_and_negative_path_indexes() { + let operation = op("x", HttpMethod::Get, "/users/{id}/posts", &[]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!( + ctx.lookup_indexed("pathSegments", 0).as_deref(), + Some("users") + ); + assert_eq!( + ctx.lookup_indexed("pathSegments", -1).as_deref(), + Some("posts") + ); + assert_eq!(ctx.lookup_indexed("pathSegments", 5), None); + } + + #[test] + fn lookup_indexed_returns_none_for_unknown_array_name() { + let operation = op("x", HttpMethod::Get, "/pets", &[]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(ctx.lookup_indexed("nothing", 0), None); + } +} diff --git a/src/plan/naming/defaults.rs b/src/plan/naming/defaults.rs new file mode 100644 index 0000000..ea9ef73 --- /dev/null +++ b/src/plan/naming/defaults.rs @@ -0,0 +1,118 @@ +//! Hardcoded defaults — applied when the user did not configure a +//! `Naming` for a given key. The spec says these are NOT expressed as +//! `Rule` chains, so they live as plain Rust here. +//! +//! Defaults: +//! * methodName: camelCase(operationId), else camelCase(method + '_' + path segments joined by `_`). +//! Errors if both fail. +//! * group: pascalCase(tags[0]), else pascalCase(pathSegments[0]), else "Default". + +use crate::plan::naming::{case::apply as apply_case, config::Case, context::OperationContext}; + +#[derive(Debug)] +pub(crate) enum DefaultMethodNameFailure { + /// Neither operationId nor a usable path-segment fallback was available. + NoSource, +} + +pub(crate) fn default_method_name( + ctx: &OperationContext<'_>, +) -> Result { + if let Some(id) = ctx.operation_id + && !id.is_empty() + { + return Ok(apply_case(id, Case::Camel)); + } + if !ctx.path_segments.is_empty() { + let suffix = ctx.path_segments.join("_"); + return Ok(apply_case( + &format!("{}_{}", ctx.method, suffix), + Case::Camel, + )); + } + Err(DefaultMethodNameFailure::NoSource) +} + +pub(crate) fn default_group(ctx: &OperationContext<'_>) -> String { + if let Some(tag) = ctx.tags.first() + && !tag.is_empty() + { + return apply_case(tag, Case::Pascal); + } + if let Some(segment) = ctx.path_segments.first() + && !segment.is_empty() + { + return apply_case(segment, Case::Pascal); + } + "Default".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::{ + canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, + schema::{SchemaScalar, SchemaType}, + }; + + fn op(id: &str, method: HttpMethod, path: &str, tags: &[&str]) -> OperationDef { + OperationDef { + operation_id: id.to_string(), + tags: tags.iter().map(|s| s.to_string()).collect(), + method, + path: path.to_string(), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Scalar( + SchemaScalar::Boolean, + )))), + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + #[test] + fn default_method_name_uses_camel_case_of_operation_id_when_present() { + let operation = op("list_pets", HttpMethod::Get, "/pets", &[]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(default_method_name(&ctx).unwrap(), "listPets"); + } + + #[test] + fn default_method_name_falls_back_to_method_plus_path_when_operation_id_missing() { + let operation = op("", HttpMethod::Get, "/users/{id}/posts", &[]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(default_method_name(&ctx).unwrap(), "getUsersIdPosts"); + } + + #[test] + fn default_method_name_errors_when_no_operation_id_and_path_is_empty() { + let operation = op("", HttpMethod::Get, "/", &[]); + let ctx = OperationContext::from_operation(&operation); + assert!(matches!( + default_method_name(&ctx), + Err(DefaultMethodNameFailure::NoSource) + )); + } + + #[test] + fn default_group_uses_pascal_case_of_first_tag_when_present() { + let operation = op("x", HttpMethod::Get, "/pets", &["pet-orders"]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(default_group(&ctx), "PetOrders"); + } + + #[test] + fn default_group_falls_back_to_path_segment_when_tags_are_empty() { + let operation = op("x", HttpMethod::Get, "/users/{id}", &[]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(default_group(&ctx), "Users"); + } + + #[test] + fn default_group_returns_default_when_both_sources_are_missing() { + let operation = op("x", HttpMethod::Get, "/", &[]); + let ctx = OperationContext::from_operation(&operation); + assert_eq!(default_group(&ctx), "Default"); + } +} diff --git a/src/plan/naming/engine.rs b/src/plan/naming/engine.rs new file mode 100644 index 0000000..422c2ca --- /dev/null +++ b/src/plan/naming/engine.rs @@ -0,0 +1,217 @@ +//! Single-rule evaluator and fallback-chain runner. Failure modes per +//! spec §"Failure modes": empty `from` + present `parse`, regex +//! mismatch, or any unbound name reference in `from`/`format`. + +use std::collections::HashMap; + +use crate::plan::naming::{ + case::apply as apply_case, + config::{Naming, Rule, RuleEntry}, + context::OperationContext, + template::{TemplateError, expand}, +}; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RuleFailure { + /// `parse` was present but `from` expanded to empty (nothing to match). + EmptyFromWithParse, + /// `parse` was present and did not match the expanded `from`. + ParseMismatch, + /// A template referenced an unbound name (field, indexed slot, or capture). + Unbound(String), + /// Template was malformed at parse time. This is technically a + /// config-time error caught by validation, but evaluation still has + /// to handle it defensively. + Malformed(String), +} + +pub(crate) fn evaluate_chain( + chain: &Naming, + ctx: &OperationContext<'_>, +) -> Result> { + let entries: &[RuleEntry] = match chain { + Naming::Single(entry) => std::slice::from_ref(entry), + Naming::Chain(entries) => entries.as_slice(), + }; + let mut failures = Vec::with_capacity(entries.len()); + for entry in entries { + match evaluate_entry(entry, ctx) { + Ok(s) => return Ok(s), + Err(f) => failures.push(f), + } + } + Err(failures) +} + +fn evaluate_entry(entry: &RuleEntry, ctx: &OperationContext<'_>) -> Result { + match entry { + RuleEntry::Shorthand(format_template) => { + let s = expand(format_template, ctx, &HashMap::new()).map_err(map_template_error)?; + Ok(s) + } + RuleEntry::Rule(rule) => evaluate_rule(rule, ctx), + } +} + +fn evaluate_rule(rule: &Rule, ctx: &OperationContext<'_>) -> Result { + // Step 1: expand `from` (default "" if omitted). + let from_expanded = match &rule.from { + Some(template) => expand(template, ctx, &HashMap::new()).map_err(map_template_error)?, + None => String::new(), + }; + + // Step 2: parse — only runs when present. + let captures: HashMap = match &rule.parse { + Some(spec) => { + if from_expanded.is_empty() { + return Err(RuleFailure::EmptyFromWithParse); + } + let captures = spec + .regex + .captures(&from_expanded) + .ok_or(RuleFailure::ParseMismatch)?; + spec + .regex + .capture_names() + .flatten() + .filter_map(|name| { + captures + .name(name) + .map(|m| (name.to_string(), m.as_str().to_string())) + }) + .collect() + } + None => HashMap::new(), + }; + + // Step 3: format — defaults to the expanded `from` when omitted (only + // legal when `parse` is also absent; config-time validation enforces). + let raw = match &rule.format { + Some(template) => expand(template, ctx, &captures).map_err(map_template_error)?, + None => from_expanded, + }; + + // Step 4: case transformation. + let final_value = match rule.case { + Some(case) => apply_case(&raw, case), + None => raw, + }; + + Ok(final_value) +} + +fn map_template_error(err: TemplateError) -> RuleFailure { + match err { + TemplateError::Unbound(name) => RuleFailure::Unbound(name), + TemplateError::Malformed(msg) => RuleFailure::Malformed(msg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ir::{ + canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, + schema::{SchemaScalar, SchemaType}, + }, + plan::naming::{ + config::{Case, Naming, Rule, RuleEntry}, + parse_spec::compile, + }, + }; + + fn op(id: &str, method: HttpMethod, path: &str, tags: &[&str]) -> OperationDef { + OperationDef { + operation_id: id.to_string(), + tags: tags.iter().map(|s| s.to_string()).collect(), + method, + path: path.to_string(), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Scalar( + SchemaScalar::Boolean, + )))), + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + #[test] + fn shorthand_rule_expands_template_verbatim_without_case() { + let operation = op("listPets", HttpMethod::Get, "/pets", &["Pet"]); + let ctx = OperationContext::from_operation(&operation); + let chain = Naming::Single(RuleEntry::Shorthand("{operationId}".to_string())); + assert_eq!(evaluate_chain(&chain, &ctx).unwrap(), "listPets"); + } + + #[test] + fn rule_with_parse_replaces_verb_prefix_with_capture_via_format() { + // Spec example: parse `^[^_]+_(?.+)$`, format `{capture.rest}`, case camel. + // posts_listAll → listAll. + let operation = op("posts_listAll", HttpMethod::Get, "/posts", &[]); + let ctx = OperationContext::from_operation(&operation); + let parse = compile(r"^[^_]+_(?.+)$", "").unwrap(); + let chain = Naming::Single(RuleEntry::Rule(Rule { + from: Some("{operationId}".to_string()), + parse: Some(parse), + format: Some("{capture.rest}".to_string()), + case: Some(Case::Camel), + })); + assert_eq!(evaluate_chain(&chain, &ctx).unwrap(), "listAll"); + } + + #[test] + fn rule_falls_through_to_next_entry_on_parse_mismatch() { + let operation = op("createPet", HttpMethod::Get, "/pets", &[]); + let ctx = OperationContext::from_operation(&operation); + let parse = compile(r"^v\d+_(?.+)$", "").unwrap(); + let chain = Naming::Chain(vec![ + RuleEntry::Rule(Rule { + from: Some("{operationId}".to_string()), + parse: Some(parse), + format: Some("{capture.rest}".to_string()), + case: Some(Case::Camel), + }), + RuleEntry::Rule(Rule { + from: None, + parse: None, + format: Some("{operationId}".to_string()), + case: Some(Case::Camel), + }), + ]); + assert_eq!(evaluate_chain(&chain, &ctx).unwrap(), "createPet"); + } + + #[test] + fn rule_fails_when_parse_present_but_from_expands_to_empty() { + // `from: {operationId}` but operationId is empty. + let operation = op("", HttpMethod::Get, "/pets", &[]); + let ctx = OperationContext::from_operation(&operation); + let parse = compile(r".", "").unwrap(); + let chain = Naming::Single(RuleEntry::Rule(Rule { + from: Some("{operationId}".to_string()), + parse: Some(parse), + format: Some("{capture.unused}".to_string()), + case: None, + })); + let err = evaluate_chain(&chain, &ctx).unwrap_err(); + // Empty operationId → from-template is unbound (not empty), so we + // fail with `Unbound("operationId")`. (Empty-`from`-with-`parse` is + // only reachable when `from` is omitted, which is also legal with + // `parse` per spec — but `parse` over an empty string fails too.) + assert!(matches!(err[0], RuleFailure::Unbound(_))); + } + + #[test] + fn chain_returns_all_failures_when_every_entry_fails() { + let operation = op("createPet", HttpMethod::Get, "/pets", &[]); + let ctx = OperationContext::from_operation(&operation); + let chain = Naming::Chain(vec![ + RuleEntry::Shorthand("{nonexistent1}".to_string()), + RuleEntry::Shorthand("{nonexistent2}".to_string()), + ]); + let err = evaluate_chain(&chain, &ctx).unwrap_err(); + assert_eq!(err.len(), 2); + } +} diff --git a/src/plan/naming/legacy.rs b/src/plan/naming/legacy.rs new file mode 100644 index 0000000..e7d66f5 --- /dev/null +++ b/src/plan/naming/legacy.rs @@ -0,0 +1,168 @@ +use crate::plan::naming::{case::apply as apply_case, config::Case}; + +/// Returns the PascalCase class name for a service tag, e.g. "pet" → "PetRest". +pub(crate) fn service_class_name(tag: &str) -> String { + format!("{}Rest", apply_case(tag, Case::Pascal)) +} + +/// Returns the kebab-case file stem for a service tag, e.g. "PetOrder" → "pet-order". +pub(crate) fn service_file_stem(tag: &str) -> String { + apply_case(tag, Case::Kebab) +} + +/// Returns the PascalCase synthesized envelope name for an operation's +/// path/query/header/body fields, e.g. "listPets" → "ListPetsParams". +/// +/// Suffixed with `Params` (not `Request`) to avoid colliding with body +/// schemas named `Request` declared in the spec. +/// +/// Input is the resolved `method_name` (post user naming-rules), not the +/// raw spec `operationId`. Naming rules can rewrite e.g. +/// `Pet_listPets` → `listPets`, and the emitted `*Params` interface +/// must follow that rewrite so the per-operation surfaces stay +/// aligned with the property name on the service class. +pub(crate) fn request_interface_name(method_name: &str) -> String { + format!("{}Params", apply_case(method_name, Case::Pascal)) +} + +/// Returns the PascalCase error-body interface name for an operation, +/// e.g. "updatePet" → "UpdatePetError". Suffixed with `Error` (not +/// `ErrorBody`) for ergonomics — the user-facing access pattern is +/// `UpdatePetError[400]`, so the shorter suffix reads better at the +/// call site. Risk of colliding with a spec schema named +/// `Error` is real but uncommon; if it bites consumers we +/// can switch to `ErrorBody` later. +/// +/// Input is the resolved `method_name` (post user naming-rules), same +/// as `request_interface_name`. +pub(crate) fn error_interface_name(method_name: &str) -> String { + format!("{}Error", apply_case(method_name, Case::Pascal)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_class_name_converts_lowercase_tag_to_pascal_case_rest_suffix() { + assert_eq!(service_class_name("pet"), "PetRest"); + } + + #[test] + fn service_class_name_converts_camel_case_tag_to_pascal_case_rest_suffix() { + assert_eq!(service_class_name("petOrder"), "PetOrderRest"); + } + + #[test] + fn service_class_name_converts_kebab_tag_to_pascal_case_rest_suffix() { + assert_eq!(service_class_name("pet-order"), "PetOrderRest"); + } + + #[test] + fn service_file_stem_converts_pascal_case_tag_to_kebab_case() { + assert_eq!(service_file_stem("PetOrder"), "pet-order"); + } + + #[test] + fn service_file_stem_returns_single_word_lowercase_unchanged() { + assert_eq!(service_file_stem("pet"), "pet"); + } + + #[test] + fn request_interface_name_converts_camel_case_method_name_to_pascal_params() { + assert_eq!(request_interface_name("listPets"), "ListPetsParams"); + } + + #[test] + fn request_interface_name_converts_lower_method_name_to_pascal_params() { + assert_eq!(request_interface_name("updatePet"), "UpdatePetParams"); + } + + // ── Property-based: naming helpers ────────────────────────────────────── + // + // service_class_name and service_file_stem are pure case-conversions + // over arbitrary tag strings sourced from the spec. The example tests + // above pin representative cases; the properties below assert global + // invariants so adversarial inputs (whitespace, control chars, unicode) + // can't sneak in malformed identifiers / file stems. + + use proptest::prelude::*; + + /// First char must satisfy TS IdentifierStart (we restrict to ASCII + /// alphabetic + `_` + `$`); subsequent chars must be IdentifierPart. + /// Matches `is_valid_identifier` in `emit::typescript`. + fn is_ts_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + } + + /// Result of `service_file_stem` should be kebab-case ASCII: lowercase + /// letters, digits, and hyphens, with no leading/trailing hyphen and no + /// consecutive hyphens. Returns true for the empty string (e.g. when the + /// input was all non-alphanumeric). + fn is_kebab_case_ascii(value: &str) -> bool { + if value.is_empty() { + return true; + } + if value.starts_with('-') || value.ends_with('-') { + return false; + } + let mut prev_hyphen = false; + for ch in value.chars() { + match ch { + 'a'..='z' | '0'..='9' => prev_hyphen = false, + '-' => { + if prev_hyphen { + return false; + } + prev_hyphen = true; + } + _ => return false, + } + } + true + } + + proptest! { + /// `service_class_name` is only fed values that survive the + /// `tag_first_operation_grouper` policy check (tags non-empty after + /// trim, ASCII identifier-shaped). We test the policy-clean subset + /// here — alphabetic tags with optional hyphens/underscores — because + /// that's the surface the rest of the planner actually sees. + #[test] + fn service_class_name_emits_valid_ts_identifier_with_rest_suffix( + tag in "[a-zA-Z][a-zA-Z0-9_-]{0,31}" + ) { + let class_name = service_class_name(&tag); + prop_assert!(class_name.ends_with("Rest")); + prop_assert!( + is_ts_identifier(&class_name), + "service_class_name produced non-identifier {class_name:?} for tag {tag:?}", + ); + } + + /// Scoped to ASCII tags because the policy layer in + /// `tag_first_operation_grouper` rejects any operation whose tag + /// would not produce a valid Angular-style file stem. Non-ASCII + /// inputs to `service_file_stem` are reachable in code but never in + /// practice — locking the kebab-case invariant on the ASCII subset + /// is what consumers actually rely on. + #[test] + fn service_file_stem_produces_kebab_case_or_empty_for_ascii_tags( + tag in "[ -~]{0,32}" + ) { + let stem = service_file_stem(&tag); + prop_assert!( + is_kebab_case_ascii(&stem), + "service_file_stem produced non-kebab {stem:?} for tag {tag:?}", + ); + } + + } +} diff --git a/src/plan/naming/mod.rs b/src/plan/naming/mod.rs new file mode 100644 index 0000000..8be80ab --- /dev/null +++ b/src/plan/naming/mod.rs @@ -0,0 +1,214 @@ +//! Naming module — pre-emit derivation of `methodName` and `group` for +//! each operation, plus the formatting helpers (class name, file stem, +//! request-interface name, body-field inference) that consume those +//! resolved names. +//! +//! Submodules: +//! * `legacy` — formatting helpers fixed by the project (not user-configurable). +//! * `config`, `context`, `template`, `case`, `parse_spec`, `engine`, +//! `defaults` — the rule engine described in `docs/naming-spec.md`. +//! +//! Public surface (re-exported here): `NamingResolver`, the `NamingConfig` +//! / `Naming` / `Rule` / `RuleEntry` / `Case` types, the `compile_parse_spec` +//! helper, and the four legacy formatting helpers. + +mod case; +mod config; +mod context; +mod defaults; +mod engine; +mod legacy; +mod parse_spec; +mod template; + +pub use config::NamingConfig; +pub(crate) use config::{Case, Naming, Rule, RuleEntry}; +pub(crate) use legacy::{ + error_interface_name, request_interface_name, service_class_name, service_file_stem, +}; +pub(crate) use parse_spec::compile as compile_parse_spec; + +use crate::{ + error::{Diagnostic, Reporter}, + ir::canonical::OperationDef, +}; +use context::OperationContext; +use defaults::{default_group, default_method_name}; +use engine::{RuleFailure, evaluate_chain}; + +/// Resolved-naming entry point used by the planner. Holds the +/// user-supplied (validated, regex-compiled) config and exposes +/// per-operation lookups. +#[derive(Debug, Clone, Default)] +pub(crate) struct NamingResolver { + pub(crate) config: NamingConfig, +} + +impl NamingResolver { + pub(crate) const fn new(config: NamingConfig) -> Self { + Self { config } + } + + pub(crate) fn method_name( + &self, + operation: &OperationDef, + reporter: &Reporter<'_>, + ) -> Result { + let ctx = OperationContext::from_operation(operation); + self.config.method_name.as_ref().map_or_else( + || { + default_method_name(&ctx).map_err(|_| { + Diagnostic::policy_violation( + reporter, + "naming-resolution", + format!( + "Could not derive a default methodName for operation {} {} (no operationId, and path produced no segments).", + operation.method, operation.path, + ), + ) + }) + }, + |naming| { + evaluate_chain(naming, &ctx).map_err(|failures| { + naming_resolution_error(reporter, "methodName", operation, &failures) + }) + }, + ) + } + + pub(crate) fn group( + &self, + operation: &OperationDef, + reporter: &Reporter<'_>, + ) -> Result { + let ctx = OperationContext::from_operation(operation); + self.config.group.as_ref().map_or_else( + || Ok(default_group(&ctx)), + |naming| { + evaluate_chain(naming, &ctx) + .map_err(|failures| naming_resolution_error(reporter, "group", operation, &failures)) + }, + ) + } +} + +fn naming_resolution_error( + reporter: &Reporter<'_>, + key: &str, + operation: &OperationDef, + failures: &[RuleFailure], +) -> Diagnostic { + let formatted: String = failures + .iter() + .enumerate() + .map(|(i, f)| format!(" [{}] {}", i, format_failure(f))) + .collect::>() + .join("\n"); + Diagnostic::policy_violation( + reporter, + "naming-resolution", + format!( + "Failed to resolve `{}` for operation {} {} (operationId={}). All rules in the fallback chain failed:\n{}", + key, operation.method, operation.path, operation.operation_id, formatted, + ), + ) +} + +fn format_failure(failure: &RuleFailure) -> String { + match failure { + RuleFailure::EmptyFromWithParse => { + "expanded `from` was empty and `parse` is present (nothing to match)".to_string() + } + RuleFailure::ParseMismatch => "regex `parse` did not match the expanded `from`".to_string(), + RuleFailure::Unbound(name) => format!("template referenced unbound name `{{{name}}}`"), + RuleFailure::Malformed(msg) => format!("template malformed: {msg}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + error::DiagnosticCode, + ir::{ + canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, + schema::{SchemaScalar, SchemaType}, + }, + test_support::test_ctx, + }; + + fn op(id: &str, tags: &[&str], path: &str) -> OperationDef { + OperationDef { + operation_id: id.to_string(), + tags: tags.iter().map(|s| s.to_string()).collect(), + method: HttpMethod::Get, + path: path.to_string(), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Scalar( + SchemaScalar::Boolean, + )))), + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + #[test] + fn naming_resolver_returns_default_method_name_when_unconfigured() { + let resolver = NamingResolver::default(); + let mut ctx = test_ctx(); + let operation = op("list_pets", &["Pet"], "/pets"); + assert_eq!( + resolver.method_name(&operation, &ctx.reporter()).unwrap(), + "listPets" + ); + } + + #[test] + fn naming_resolver_returns_default_group_when_unconfigured() { + let resolver = NamingResolver::default(); + let mut ctx = test_ctx(); + let operation = op("x", &["pet-orders"], "/pets"); + assert_eq!( + resolver.group(&operation, &ctx.reporter()).unwrap(), + "PetOrders" + ); + } + + #[test] + fn naming_resolver_applies_user_supplied_method_name_chain() { + let chain = Naming::Single(RuleEntry::Rule(Rule { + from: Some("{operationId}".to_string()), + parse: Some(compile_parse_spec(r"^[^_]+_(?.+)$", "").unwrap()), + format: Some("{capture.rest}".to_string()), + case: Some(Case::Camel), + })); + let resolver = NamingResolver::new(NamingConfig { + method_name: Some(chain), + group: None, + }); + let mut ctx = test_ctx(); + let operation = op("posts_listAll", &["Posts"], "/posts"); + assert_eq!( + resolver.method_name(&operation, &ctx.reporter()).unwrap(), + "listAll" + ); + } + + #[test] + fn naming_resolver_emits_policy_violation_when_all_rules_fail() { + let chain = Naming::Single(RuleEntry::Shorthand("{nonexistent}".to_string())); + let resolver = NamingResolver::new(NamingConfig { + method_name: Some(chain), + group: None, + }); + let mut ctx = test_ctx(); + let operation = op("x", &["Pet"], "/pets"); + let err = resolver + .method_name(&operation, &ctx.reporter()) + .unwrap_err(); + assert_eq!(err.code, DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("naming-resolution")); + assert!(err.message.contains("methodName")); + } +} diff --git a/src/plan/naming/parse_spec.rs b/src/plan/naming/parse_spec.rs new file mode 100644 index 0000000..a892dac --- /dev/null +++ b/src/plan/naming/parse_spec.rs @@ -0,0 +1,90 @@ +//! Compiled user-supplied `parse` regex. The NAPI boundary delivers the +//! source pattern and flags string (split out from the JS RegExp on the +//! wrapper side); we compile here once at config time so per-operation +//! evaluation is a cheap `regex.captures()` call. + +use regex::{Regex, RegexBuilder}; + +#[derive(Debug, Clone)] +pub(crate) struct CompiledParseSpec { + pub(crate) regex: Regex, +} + +/// Why a `parse` spec could not be compiled. Surfaced at config-validation +/// time as an `E_INVALID_OPTION` diagnostic so the user sees the error +/// before any generation work runs. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum CompileError { + UnsupportedFlag(char), + InvalidPattern(String), +} + +/// Compile a user `parse` regex. Supported flags: `i`, `m`, `s` (subset +/// of JS RegExp that maps cleanly to Rust's `regex` crate). Any other +/// flag — including `g`/`y`/`u` — is rejected loudly rather than +/// silently ignored, so JS authors don't get surprised when their JS +/// pattern relies on a flag the Rust engine cannot honour. +pub(crate) fn compile(source: &str, flags: &str) -> Result { + let mut builder = RegexBuilder::new(source); + for ch in flags.chars() { + match ch { + 'i' => { + builder.case_insensitive(true); + } + 'm' => { + builder.multi_line(true); + } + 's' => { + builder.dot_matches_new_line(true); + } + other => return Err(CompileError::UnsupportedFlag(other)), + } + } + let regex = builder + .build() + .map_err(|err| CompileError::InvalidPattern(err.to_string()))?; + Ok(CompiledParseSpec { regex }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compile_accepts_empty_flags() { + let spec = compile(r"^(?.+)$", "").expect("should compile"); + assert!(spec.regex.is_match("hello")); + } + + #[test] + fn compile_honours_case_insensitive_flag() { + let spec = compile(r"^foo$", "i").expect("should compile"); + assert!(spec.regex.is_match("FOO")); + } + + #[test] + fn compile_rejects_unsupported_global_flag() { + let err = compile(r".", "g").expect_err("`g` should be rejected"); + assert_eq!(err, CompileError::UnsupportedFlag('g')); + } + + #[test] + fn compile_rejects_unsupported_unicode_flag() { + let err = compile(r".", "u").expect_err("`u` should be rejected"); + assert_eq!(err, CompileError::UnsupportedFlag('u')); + } + + #[test] + fn compile_propagates_invalid_pattern_error() { + let err = compile("[", "").expect_err("unterminated class should fail"); + assert!(matches!(err, CompileError::InvalidPattern(_))); + } + + #[test] + fn compile_supports_named_captures() { + let spec = compile(r"^(?[a-z]+)_(?.+)$", "").expect("should compile"); + let captures = spec.regex.captures("list_pets").expect("should match"); + assert_eq!(captures.name("verb").unwrap().as_str(), "list"); + assert_eq!(captures.name("rest").unwrap().as_str(), "pets"); + } +} diff --git a/src/plan/naming/template.rs b/src/plan/naming/template.rs new file mode 100644 index 0000000..2e0fd55 --- /dev/null +++ b/src/plan/naming/template.rs @@ -0,0 +1,172 @@ +//! Template expander. Supports exactly three productions: +//! * `{fieldName}` — context field by name +//! * `{arrayField[N]}` — array index (negative allowed) +//! * `{capture.name}` — regex named capture (explicit namespace) +//! +//! Unbound references and malformed templates surface as +//! `TemplateError`; the rule evaluator converts these into rule failures +//! per spec §"Failure modes". + +use std::collections::HashMap; + +use crate::plan::naming::context::OperationContext; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum TemplateError { + /// `{...}` referenced a name not in the context. + Unbound(String), + /// Malformed template: unclosed `{`, malformed index, etc. + Malformed(String), +} + +pub(crate) fn expand( + template: &str, + ctx: &OperationContext<'_>, + captures: &HashMap, +) -> Result { + let mut out = String::with_capacity(template.len()); + let chars: Vec = template.chars().collect(); + let mut i = 0; + while i < chars.len() { + let ch = chars[i]; + if ch == '{' { + let end = chars[i..] + .iter() + .position(|c| *c == '}') + .ok_or_else(|| TemplateError::Malformed(format!("unclosed `{{` at offset {i}")))?; + let token: String = chars[i + 1..i + end].iter().collect(); + out.push_str(&resolve_token(&token, ctx, captures)?); + i += end + 1; + } else { + out.push(ch); + i += 1; + } + } + Ok(out) +} + +fn resolve_token( + token: &str, + ctx: &OperationContext<'_>, + captures: &HashMap, +) -> Result { + if let Some(rest) = token.strip_prefix("capture.") { + return captures + .get(rest) + .cloned() + .ok_or_else(|| TemplateError::Unbound(token.to_string())); + } + if let Some((array_name, idx_str)) = parse_indexed(token) { + let idx: i32 = idx_str + .parse() + .map_err(|_| TemplateError::Malformed(format!("invalid index in `{token}`")))?; + return ctx + .lookup_indexed(array_name, idx) + .ok_or_else(|| TemplateError::Unbound(token.to_string())); + } + ctx + .lookup(token) + .ok_or_else(|| TemplateError::Unbound(token.to_string())) +} + +fn parse_indexed(token: &str) -> Option<(&str, &str)> { + let open = token.find('[')?; + if !token.ends_with(']') { + return None; + } + Some((&token[..open], &token[open + 1..token.len() - 1])) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::ir::{ + canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, + schema::{SchemaScalar, SchemaType}, + }; + + fn ctx<'a>(operation: &'a OperationDef) -> OperationContext<'a> { + OperationContext::from_operation(operation) + } + + fn op() -> OperationDef { + OperationDef { + operation_id: "listPets".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Get, + path: "/pets/{id}".to_string(), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Scalar( + SchemaScalar::Boolean, + )))), + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + #[test] + fn expand_substitutes_plain_field_reference() { + let operation = op(); + let result = expand("{operationId}", &ctx(&operation), &HashMap::new()).unwrap(); + assert_eq!(result, "listPets"); + } + + #[test] + fn expand_substitutes_indexed_path_segment() { + let operation = op(); + let result = expand( + "{method}_{pathSegments[0]}", + &ctx(&operation), + &HashMap::new(), + ) + .unwrap(); + assert_eq!(result, "get_pets"); + } + + #[test] + fn expand_substitutes_named_capture_in_capture_namespace() { + let operation = op(); + let mut captures = HashMap::new(); + captures.insert("rest".to_string(), "listAll".to_string()); + let result = expand("{capture.rest}", &ctx(&operation), &captures).unwrap(); + assert_eq!(result, "listAll"); + } + + #[test] + fn expand_returns_unbound_for_unknown_field() { + let operation = op(); + let err = expand("{whatever}", &ctx(&operation), &HashMap::new()).unwrap_err(); + assert_eq!(err, TemplateError::Unbound("whatever".to_string())); + } + + #[test] + fn expand_returns_unbound_for_out_of_range_index() { + let operation = op(); + let err = expand("{pathSegments[7]}", &ctx(&operation), &HashMap::new()).unwrap_err(); + assert_eq!(err, TemplateError::Unbound("pathSegments[7]".to_string())); + } + + #[test] + fn expand_returns_unbound_for_missing_capture() { + let operation = op(); + let err = expand("{capture.missing}", &ctx(&operation), &HashMap::new()).unwrap_err(); + assert_eq!(err, TemplateError::Unbound("capture.missing".to_string())); + } + + #[test] + fn expand_returns_malformed_for_unclosed_brace() { + let operation = op(); + let err = expand("{operationId", &ctx(&operation), &HashMap::new()).unwrap_err(); + assert!(matches!(err, TemplateError::Malformed(_))); + } + + #[test] + fn expand_returns_malformed_for_non_numeric_index() { + let operation = op(); + let err = expand("{pathSegments[abc]}", &ctx(&operation), &HashMap::new()).unwrap_err(); + assert!(matches!(err, TemplateError::Malformed(_))); + } +} diff --git a/src/plan/services.rs b/src/plan/services.rs new file mode 100644 index 0000000..e00b635 --- /dev/null +++ b/src/plan/services.rs @@ -0,0 +1,897 @@ +//! Planning services: operation grouping, body-layout resolution, and +//! per-operation request-contract construction. + +use std::collections::{BTreeSet, HashMap}; + +use crate::{ + error::{Diagnostic, Reporter}, + ir::{ + canonical::{BodyContent, BodyField, OperationDef, RequestBodyDef}, + schema::SchemaType, + }, + plan::artifact_plan::{ + PlannedFormField, PlannedHeader, PlannedRequestBody, PlannedRequestContract, + PlannedRequestField, RequestFieldKind, + }, +}; + +// --------------------------------------------------------------------------- +// Section 1: Operation grouper +// --------------------------------------------------------------------------- + +pub(crate) type GroupedOperations<'a> = Vec<(String, Vec<(&'a OperationDef, String)>)>; + +/// Groups operations by their resolved `group` name. Returns +/// `(group_name, Vec<(operation, method_name)>)` pairs in +/// operation-discovery order; downstream sorting is the planner's job +/// (`resolve_service_plans`). Both `group` and `method_name` are +/// resolved up-front via the `NamingResolver` so each operation is +/// touched exactly once. +pub(crate) fn group_operations<'a>( + operations: &'a [OperationDef], + resolver: &crate::plan::naming::NamingResolver, + reporter: &Reporter<'_>, +) -> Result, Diagnostic> { + let mut groups: GroupedOperations<'a> = Vec::new(); + let mut group_indexes = HashMap::::new(); + + for operation in operations { + let group_name = resolver.group(operation, reporter)?; + let method_name = resolver.method_name(operation, reporter)?; + + let group_index = group_indexes.get(&group_name).copied().unwrap_or_else(|| { + let index = groups.len(); + let key = group_name.clone(); + groups.push((group_name, Vec::new())); + group_indexes.insert(key, index); + index + }); + + groups[group_index].1.push((operation, method_name)); + } + + Ok(groups) +} + +// --------------------------------------------------------------------------- +// Section 2: Body resolution +// --------------------------------------------------------------------------- + +/// Translate an IR `RequestBodyDef` into the planner's `PlannedRequestBody`, +/// applying the smart-flatten rule: +/// +/// - Inline JSON `type: object` → `FlatJson` with the body's properties +/// hoisted to top-level request fields. The body envelope's `required` +/// flag is propagated to each hoisted property's `optional` marker so an +/// `required: false` body never produces required fields on the params +/// interface. +/// - JSON `$ref` (named schema) or any non-object JSON shape → `Nested`, +/// preserving the spec author's type as a single `body: T` field. +/// - Form bodies (multipart / urlencoded) always flatten their fields to +/// top-level, since `BodyFieldType` (`Blob | File`, …) can't compose +/// back under the source schema name. Form fields are sorted +/// alphabetically by name so the emitted interface stays stable across +/// spec re-orderings. +fn plan_request_body<'ir>(body: Option<&'ir RequestBodyDef>) -> Option> { + let body = body?; + match &body.content { + BodyContent::Json(SchemaType::InlineObject { properties }) => { + let envelope_required = body.required; + let hoisted = properties + .iter() + .map(|property| PlannedRequestField { + name: property.name.clone(), + optional: !envelope_required || !property.required, + ty: &property.ty, + kind: RequestFieldKind::Body, + }) + .collect(); + Some(PlannedRequestBody::FlatJson { + properties: hoisted, + required: envelope_required, + }) + } + BodyContent::Json(ty) => Some(PlannedRequestBody::Nested { + ty, + optional: !body.required, + }), + BodyContent::Multipart { fields, .. } => Some(PlannedRequestBody::Multipart { + fields: plan_form_fields(fields), + }), + BodyContent::UrlEncoded { fields, .. } => Some(PlannedRequestBody::UrlEncoded { + fields: plan_form_fields(fields), + }), + } +} + +fn plan_form_fields<'ir>(fields: &'ir [BodyField]) -> Vec> { + let mut out: Vec> = fields + .iter() + .map(|f| PlannedFormField { + name: f.name.clone(), + optional: !f.required, + ty: &f.ty, + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// Reject a contract whose top-level body field names (from `FlatJson` +/// properties, `Multipart` fields, or `UrlEncoded` fields) clash with the +/// path/query parameter names already on `fields`. Nested-body operations +/// have nothing to check here — their body sits on the dedicated `body` +/// slot under the literal key `body`. +/// +/// The diagnostic mirrors the original (pre-smart-flatten) message and +/// nudges the spec author toward the natural escape hatch: hoist the +/// inline body to a top-level `$ref` so it lands on the `body` slot +/// instead of flattening. +fn check_body_field_collisions( + fields: &[PlannedRequestField], + body: Option<&PlannedRequestBody>, + operation_id: &str, + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + let path_query_names: std::collections::BTreeSet<&str> = + fields.iter().map(|f| f.name.as_ref()).collect(); + if path_query_names.is_empty() { + return Ok(()); + } + let body_names: Vec<&str> = match body { + Some(PlannedRequestBody::FlatJson { properties, .. }) => { + properties.iter().map(|p| p.name.as_ref()).collect() + } + Some(PlannedRequestBody::Multipart { fields } | PlannedRequestBody::UrlEncoded { fields }) => { + fields.iter().map(|f| f.name.as_ref()).collect() + } + _ => return Ok(()), + }; + let colliding: Vec<&str> = body_names + .into_iter() + .filter(|n| path_query_names.contains(n)) + .collect(); + if colliding.is_empty() { + return Ok(()); + } + let names = colliding.join(", "); + Err(Diagnostic::policy_violation( + reporter, + "field-collision", + format!( + "operationId '{operation_id}': body fields [{names}] duplicate path/query parameter names. \ + Rename the colliding fields in the OpenAPI spec, or hoist the body schema to a named `$ref` so it nests under `body`." + ), + )) +} + +// --------------------------------------------------------------------------- +// Section 3: Request-contract planning +// --------------------------------------------------------------------------- + +fn check_path_query_collisions( + fields: &[PlannedRequestField], + operation_id: &str, + reporter: &Reporter<'_>, +) -> Result<(), Diagnostic> { + let path_set: BTreeSet<&str> = fields + .iter() + .filter(|f| f.kind == RequestFieldKind::Path) + .map(|f| f.name.as_ref()) + .collect(); + let colliding: Vec<&str> = fields + .iter() + .filter(|f| f.kind == RequestFieldKind::Query && path_set.contains(f.name.as_ref())) + .map(|f| f.name.as_ref()) + .collect(); + if !colliding.is_empty() { + let names = colliding.join(", "); + return Err(Diagnostic::policy_violation( + reporter, + "field-collision", + format!( + "operationId '{operation_id}': path and query parameters share names [{names}], \ + which would produce duplicate fields in the generated request contract. \ + Rename the colliding parameters in the OpenAPI spec." + ), + )); + } + Ok(()) +} + +pub(crate) fn plan_request_contract<'ir>( + operation: &'ir OperationDef, + reporter: &Reporter<'_>, +) -> Result, Diagnostic> { + let mut fields: Vec> = Vec::new(); + + for input in &operation.request.inputs { + let kind = match input.source { + crate::ir::canonical::RequestInputSource::Path => RequestFieldKind::Path, + crate::ir::canonical::RequestInputSource::Query => RequestFieldKind::Query, + }; + fields.push(PlannedRequestField { + name: input.name.clone(), + optional: !input.required, + ty: &input.ty, + kind, + }); + } + + let headers: Vec> = operation + .request + .headers + .iter() + .map(|header| PlannedHeader { + name: header.name.clone(), + optional: !header.required, + ty: &header.ty, + }) + .collect(); + + check_path_query_collisions(&fields, &operation.operation_id, reporter)?; + + let body = plan_request_body(operation.request.body.as_ref()); + + check_body_field_collisions(&fields, body.as_ref(), &operation.operation_id, reporter)?; + + Ok(PlannedRequestContract { + fields, + headers, + body, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + mod grouper { + use crate::{ + ir::{ + canonical::{HttpMethod, OperationDef, RequestDef, ResponseContent}, + schema::{SchemaScalar, SchemaType}, + }, + plan::{naming::NamingResolver, services::group_operations}, + test_support::test_ctx, + }; + + fn operation(id: &str, tags: Vec<&str>) -> OperationDef { + OperationDef { + operation_id: id.to_string(), + tags: tags.into_iter().map(str::to_string).collect(), + method: HttpMethod::Get, + path: format!("/{id}"), + request: RequestDef::default(), + response: Some(ResponseContent::Json(Some(SchemaType::Scalar( + SchemaScalar::Boolean, + )))), + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + #[test] + fn tag_first_operation_grouper_preserves_group_and_operation_discovery_order() { + let operations = [ + operation("listPets", vec!["Pet"]), + operation("listAdoptions", vec!["Adoption"]), + operation("getPet", vec!["Pet"]), + ]; + let mut ctx = test_ctx(); + let resolver = NamingResolver::default(); + let groups = + group_operations(&operations, &resolver, &ctx.reporter()).expect("grouping succeeds"); + + assert_eq!( + groups + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + vec!["Pet", "Adoption"] + ); + assert_eq!( + groups[0] + .1 + .iter() + .map(|(operation, _method_name)| operation.operation_id.as_str()) + .collect::>(), + vec!["listPets", "getPet"] + ); + } + + #[test] + fn tagless_operations_fall_back_to_path_derived_group_with_default_resolver() { + // The previous `tag_first_operation_grouper_rejects_tagless_operations` + // test asserted a policy violation; with the configurable naming + // engine, the default `group` rule falls back to + // `pascalCase(pathSegments[0])` when tags are missing. + let mut ctx = test_ctx(); + let resolver = NamingResolver::default(); + let ops = [operation("listPets", Vec::new())]; + let groups = group_operations(&ops, &resolver, &ctx.reporter()) + .expect("default resolver groups by path segment when tags are absent"); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0, "ListPets"); + } + } + + mod body { + use super::super::plan_request_body; + use crate::{ + ir::{ + canonical::{BodyContent, RequestBodyDef}, + schema::{SchemaProperty, SchemaScalar, SchemaType}, + }, + plan::artifact_plan::PlannedRequestBody, + }; + + #[test] + fn returns_none_when_body_is_absent() { + assert!(plan_request_body(None).is_none()); + } + + #[test] + fn ref_body_stays_nested_with_named_schema_preserved() { + let body = RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), + }; + match plan_request_body(Some(&body)).expect("body present") { + PlannedRequestBody::Nested { ty, optional } => { + assert!(!optional); + assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "CreatePetRequest")); + } + other => panic!("expected nested ref body, got {other:?}"), + } + } + + #[test] + fn inline_object_body_hoists_properties_with_required_flag_propagated() { + let body = RequestBodyDef { + required: false, + content: BodyContent::Json(SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "status".into(), + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + description: None, + deprecated: false, + }], + }), + }; + match plan_request_body(Some(&body)).expect("body present") { + PlannedRequestBody::FlatJson { + properties, + required, + } => { + assert!(!required, "envelope marked optional in fixture"); + assert_eq!(properties.len(), 1); + assert_eq!(properties[0].name.as_ref(), "status"); + // Required property under an optional envelope ⇒ field is optional. + assert!(properties[0].optional); + } + other => panic!("expected FlatJson, got {other:?}"), + } + } + + #[test] + fn non_object_json_body_stays_nested() { + let body = RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Scalar(SchemaScalar::String)), + }; + assert!(matches!( + plan_request_body(Some(&body)), + Some(PlannedRequestBody::Nested { .. }) + )); + } + } + + mod contract { + use crate::{ + ir::{ + canonical::{ + BodyContent, HeaderDef, HttpMethod, OperationDef, RequestBodyDef, RequestDef, + RequestInputDef, RequestInputSource, + }, + schema::{SchemaProperty, SchemaScalar, SchemaType}, + }, + plan::artifact_plan::{PlannedRequestBody, RequestFieldKind}, + plan::services::plan_request_contract, + test_support::test_ctx, + }; + + #[test] + fn request_contract_planner_nests_ref_body_under_dedicated_slot() { + let mut ctx = test_ctx(); + let operation = OperationDef { + operation_id: "updatePet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("UpdatePetPayload".into())), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let request = + plan_request_contract(&operation, &ctx.reporter()).expect("request contract resolves"); + + let path_fields: Vec<&str> = request + .fields + .iter() + .filter(|f| f.kind == RequestFieldKind::Path) + .map(|f| f.name.as_ref()) + .collect(); + assert_eq!(path_fields, vec!["petId"]); + match &request.body { + Some(PlannedRequestBody::Nested { ty, optional }) => { + assert!(!optional); + assert!(matches!(ty, SchemaType::Ref(name) if name.as_ref() == "UpdatePetPayload")); + } + other => panic!("expected nested ref body, got {other:?}"), + } + assert!(request.headers.is_empty()); + } + + #[test] + fn request_contract_planner_flattens_inline_object_body_to_top_level() { + // Smart-flatten: an inline `type: object` body is hoisted onto the + // request interface alongside path/query — call sites match the + // spec's authorial intent (loose parameter bag, not a named DTO). + let mut ctx = test_ctx(); + let operation = OperationDef { + operation_id: "decide".to_string(), + tags: vec!["AssetCsvImport".to_string()], + method: HttpMethod::Post, + path: "/decide".to_string(), + request: RequestDef { + inputs: Vec::new(), + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::InlineObject { + properties: vec![ + SchemaProperty { + name: "csvImportId".into(), + required: true, + ty: SchemaType::Ref("CsvImportId".into()), + description: None, + deprecated: false, + }, + SchemaProperty { + name: "doImport".into(), + required: true, + ty: SchemaType::Scalar(SchemaScalar::Boolean), + description: None, + deprecated: false, + }, + ], + }), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let request = + plan_request_contract(&operation, &ctx.reporter()).expect("request contract resolves"); + + // Path/query field list stays empty — flattened properties live on + // the FlatJson variant, not in `fields`. + assert!(request.fields.is_empty()); + let Some(PlannedRequestBody::FlatJson { + properties, + required, + }) = &request.body + else { + panic!("expected FlatJson body, got {:?}", request.body); + }; + assert!(required, "envelope required in fixture"); + assert_eq!( + properties + .iter() + .map(|p| p.name.as_ref()) + .collect::>(), + vec!["csvImportId", "doImport"] + ); + assert!(properties.iter().all(|p| !p.optional)); + } + + #[test] + fn request_contract_planner_lifts_headers_into_dedicated_list() { + let mut ctx = test_ctx(); + let operation = OperationDef { + operation_id: "tracedGet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Get, + path: "/pets".to_string(), + request: RequestDef { + inputs: Vec::new(), + headers: vec![HeaderDef { + name: "x-trace".into(), + required: false, + ty: SchemaType::Scalar(SchemaScalar::String), + }], + body: None, + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let request = + plan_request_contract(&operation, &ctx.reporter()).expect("request contract resolves"); + + assert!(request.fields.is_empty()); + assert_eq!(request.headers.len(), 1); + assert_eq!(request.headers[0].name.as_ref(), "x-trace"); + assert!(request.headers[0].optional); + } + + #[test] + fn request_contract_planner_rejects_flattened_body_property_colliding_with_path_param() { + // Smart-flatten hoists inline-object body properties to top-level, + // so a body property named the same as a path parameter would + // produce a duplicate field on the request interface. The planner + // rejects the spec; the author can recover by either renaming or + // by hoisting the body schema to a top-level `$ref` (which nests + // it under the `body` slot instead). + let operation = OperationDef { + operation_id: "createPet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::InlineObject { + properties: vec![SchemaProperty { + name: "petId".into(), + required: true, + ty: SchemaType::Ref("PetId".into()), + description: None, + deprecated: false, + }], + }), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let mut ctx = test_ctx(); + let err = plan_request_contract(&operation, &ctx.reporter()) + .expect_err("should fail on flattened body property colliding with path"); + + use crate::error::DiagnosticCode; + assert_eq!(err.code, DiagnosticCode::PolicyViolation); + assert_eq!(err.subcode, Some("field-collision")); + assert!(err.message.contains("petId")); + } + + #[test] + fn request_contract_planner_accepts_ref_body_property_sharing_path_param_name() { + // The same property collision is fine when the body is a top-level + // `$ref` — it nests under `body` rather than flattening, so the + // property name doesn't appear at the top of the request interface. + let operation = OperationDef { + operation_id: "createPet".to_string(), + tags: vec!["Pet".to_string()], + method: HttpMethod::Post, + path: "/pets/{petId}".to_string(), + request: RequestDef { + inputs: vec![RequestInputDef { + name: "petId".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Ref("PetId".into()), + }], + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Json(SchemaType::Ref("CreatePetRequest".into())), + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }; + let mut ctx = test_ctx(); + let request = plan_request_contract(&operation, &ctx.reporter()) + .expect("ref body nests under `body`, no top-level collision"); + assert!(matches!( + request.body, + Some(PlannedRequestBody::Nested { .. }) + )); + } + + #[test] + fn request_contract_planner_errors_when_path_and_query_param_share_a_name() { + let mut ctx = test_ctx(); + let err = plan_request_contract( + &OperationDef { + operation_id: "searchUsers".to_string(), + tags: vec!["User".to_string()], + method: HttpMethod::Get, + path: "/users/{id}".to_string(), + request: RequestDef { + inputs: vec![ + RequestInputDef { + name: "id".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + }, + RequestInputDef { + name: "id".into(), + source: RequestInputSource::Query, + required: false, + ty: SchemaType::Scalar(SchemaScalar::String), + }, + ], + headers: Vec::new(), + body: None, + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + }, + &ctx.reporter(), + ) + .expect_err("should fail on path/query collision"); + + use crate::error::DiagnosticCode; + assert_eq!(err.code, DiagnosticCode::PolicyViolation); + assert!(err.message.contains("id")); + } + } + + mod form_body { + use crate::{ + ir::{ + canonical::{ + ApiInfo, ApiModel, BodyContent, BodyField, BodyFieldType, HttpMethod, ModelSymbol, + OperationDef, RequestBodyDef, RequestDef, RequestInputDef, RequestInputSource, + }, + schema::{SchemaScalar, SchemaType}, + }, + plan::{ + artifact_plan::{PlannedRequestBody, resolve_service_plans}, + naming::NamingResolver, + }, + test_support::test_ctx, + }; + + fn api_model(schemas: Vec, operations: Vec) -> ApiModel { + ApiModel { + info: ApiInfo { + spec_version: "3.0.3".to_string(), + title: "Test".to_string(), + }, + schemas, + operations, + } + } + + fn multipart_operation( + operation_id: &str, + path: &str, + inputs: Vec, + body_ref: Option<&str>, + fields: Vec, + ) -> OperationDef { + OperationDef { + operation_id: operation_id.to_string(), + tags: vec!["Upload".to_string()], + method: HttpMethod::Post, + path: path.to_string(), + request: RequestDef { + inputs, + headers: Vec::new(), + body: Some(RequestBodyDef { + required: true, + content: BodyContent::Multipart { + body_ref: body_ref.map(Box::from), + fields, + }, + }), + }, + response: None, + errors: Vec::new(), + description: None, + deprecated: false, + } + } + + fn api_model_with_multipart_op() -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadAvatar", + "/avatar", + Vec::new(), + None, + vec![ + BodyField { + name: "avatar".into(), + required: true, + ty: BodyFieldType::Binary, + }, + BodyField { + name: "caption".into(), + required: false, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + ], + )], + ) + } + + fn api_model_with_multipart_unsorted_fields() -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadAssets", + "/assets", + Vec::new(), + None, + vec![ + BodyField { + name: "zeta".into(), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + BodyField { + name: "alpha".into(), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + BodyField { + name: "mu".into(), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + ], + )], + ) + } + + fn api_model_with_form_collision() -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadByFileName", + "/files/{fileName}", + vec![RequestInputDef { + name: "fileName".into(), + source: RequestInputSource::Path, + required: true, + ty: SchemaType::Scalar(SchemaScalar::String), + }], + None, + vec![ + BodyField { + name: "fileName".into(), + required: true, + ty: BodyFieldType::Scalar(SchemaScalar::String), + }, + BodyField { + name: "blob".into(), + required: true, + ty: BodyFieldType::Binary, + }, + ], + )], + ) + } + + fn api_model_with_multipart_ref_body(body_ref: &str) -> ApiModel { + api_model( + Vec::new(), + vec![multipart_operation( + "uploadForm", + "/form", + Vec::new(), + Some(body_ref), + vec![BodyField { + name: "file".into(), + required: true, + ty: BodyFieldType::Binary, + }], + )], + ) + } + + #[test] + fn plans_multipart_body_with_fields_hoisted_to_form_collection() { + let ir = api_model_with_multipart_op(); + let mut ctx = test_ctx(); + let services = + resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()).expect("ok"); + let op = &services[0].operations[0]; + match &op.request.body { + Some(PlannedRequestBody::Multipart { fields }) => { + assert!(fields.iter().any(|f| f.name.as_ref() == "avatar")); + } + other => panic!("expected multipart body, got {other:?}"), + } + // Path/query field list stays empty in this fixture; form fields + // hoist to top-level via the body slot, not via `fields`. + assert!(op.request.fields.is_empty()); + } + + #[test] + fn plans_form_fields_sorted_alphabetically() { + let ir = api_model_with_multipart_unsorted_fields(); + let mut ctx = test_ctx(); + let services = + resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()).expect("ok"); + let Some(PlannedRequestBody::Multipart { fields }) = &services[0].operations[0].request.body + else { + panic!("expected multipart body"); + }; + let names: Vec<&str> = fields.iter().map(|f| f.name.as_ref()).collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted); + } + + #[test] + fn form_field_name_collision_with_path_param_emits_field_collision() { + // Path has {fileName} and the multipart body has a `fileName` field; + // smart-flatten hoists form fields to top-level so the duplicate + // surfaces on the request interface — reject at planning time. + let ir = api_model_with_form_collision(); + let mut ctx = test_ctx(); + let err = resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()) + .expect_err("hoisted form fields collide with path param"); + assert_eq!(err.subcode, Some("field-collision")); + assert!(err.message.contains("fileName")); + } + + #[test] + fn multipart_ref_body_still_flattens_fields_under_smart_rule() { + // Even when the multipart body carries a named source schema, we + // can't render the schema's name as a TS type — `BodyFieldType` + // (Blob | File, …) does not compose into the source `SchemaType`. + // So multipart bodies always flatten regardless of `body_ref`. + let ir = api_model_with_multipart_ref_body("UploadForm"); + let mut ctx = test_ctx(); + let services = + resolve_service_plans(&ir, &NamingResolver::default(), &ctx.reporter()).expect("ok"); + assert!(matches!( + services[0].operations[0].request.body, + Some(PlannedRequestBody::Multipart { .. }) + )); + } + } +} diff --git a/src/result.rs b/src/result.rs new file mode 100644 index 0000000..84ae7b3 --- /dev/null +++ b/src/result.rs @@ -0,0 +1,69 @@ +use napi_derive::napi; + +use crate::ir::canonical::ApiModel; + +#[napi(object)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GenerateSummary { + /// Display-normalized path of the source spec, as it appears in the + /// generated-artifact banner and in diagnostics' `path` field. Lets + /// consumers correlate a result with the input they passed; the value + /// is the supplied path with separators normalized, never resolved. + pub normalized_source_path: String, + pub spec_version: String, + pub title: String, + // u32 in the canonical IR; surfaces as a plain JS `number` (no BigInt + // gymnastics) — lossless for any plausible spec size. + pub path_count: u32, + pub operation_count: u32, + pub schema_count: u32, +} + +impl GenerateSummary { + /// Builds a summary from the canonical `ApiModel`. Counts are derived + /// from the IR — what the generator will actually emit — rather than + /// from the pre-normalize document, so a normalization that drops or + /// fails on a schema is reflected in the user-facing summary. + pub(crate) fn from_ir(normalized_source_path: String, ir: &ApiModel) -> Self { + // Operation paths repeat per HTTP method (GET/POST/... on the same path + // count as one path), so dedup. Vec + sort_unstable + dedup avoids the + // per-node allocation of BTreeSet for what's only used as a count. + let mut paths: Vec<&str> = ir.operations.iter().map(|op| op.path.as_str()).collect(); + paths.sort_unstable(); + paths.dedup(); + // Per-document caps in `src/options.rs` keep these well below u32::MAX; + // the clamp is a defence-in-depth guard, and the debug_assert traps any + // future cap relaxation that would actually exceed the surface type. + Self { + normalized_source_path, + spec_version: ir.info.spec_version.clone(), + title: ir.info.title.clone(), + path_count: clamp_count(paths.len()), + operation_count: clamp_count(ir.operations.len()), + schema_count: clamp_count(ir.schemas.len()), + } + } +} + +const U32_MAX_AS_USIZE: usize = u32::MAX as usize; + +fn clamp_count(n: usize) -> u32 { + debug_assert!(n <= U32_MAX_AS_USIZE, "IR count exceeded u32::MAX: {n}"); + u32::try_from(usize::min(n, U32_MAX_AS_USIZE)).unwrap_or(u32::MAX) +} + +/// A single generated artifact. `contents` always carries the emitted +/// source; callers that only need on-disk output can pass `outputPath` +/// and ignore the array. +#[napi(object)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GeneratedArtifact { + pub path: String, + pub contents: String, +} + +impl GeneratedArtifact { + pub(crate) const fn new(path: String, contents: String) -> Self { + Self { path, contents } + } +} diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..c0edf0e --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,257 @@ +use std::rc::Rc; + +use crate::{ + error::{Diagnostic, Reporter}, + ir::{ + canonical::{BodyFieldType, HttpMethod, ResponseContent}, + schema::{SchemaProperty, SchemaScalar, SchemaType}, + }, + plan::artifact_plan::{ + PlannedFormField, PlannedHeader, PlannedOperation, PlannedRequestBody, PlannedRequestContract, + PlannedRequestField, RequestFieldKind, + }, +}; + +/// Self-contained reporter scaffolding for tests. Owns the warnings vec so +/// individual tests don't have to manage the borrow themselves; expose +/// the warnings borrow through `.reporter()` so tests can either ignore +/// warnings (treat as `&Reporter<'_>`) or push warnings via +/// `&mut Reporter<'_>`. +pub(crate) struct TestReporter { + pub(crate) path: Rc, + pub(crate) warnings: Vec, +} + +impl TestReporter { + pub(crate) fn new(path: impl Into>) -> Self { + Self { + path: path.into(), + warnings: Vec::new(), + } + } + + pub(crate) fn reporter(&mut self) -> Reporter<'_> { + Reporter::new(Rc::clone(&self.path), &mut self.warnings) + } +} + +pub(crate) fn test_ctx() -> TestReporter { + TestReporter::new("test") +} + +pub(crate) fn property(name: &str, required: bool, ty: SchemaType) -> SchemaProperty { + SchemaProperty { + name: name.into(), + required, + ty, + description: None, + deprecated: false, + } +} + +pub(crate) fn nullable_property(name: &str, required: bool, ty: SchemaType) -> SchemaProperty { + SchemaProperty { + name: name.into(), + required, + ty: SchemaType::Nullable(Box::new(ty)), + description: None, + deprecated: false, + } +} + +// ── Request-field / operation fixture builders ──────────────────────────────── + +/// A plain `string` scalar — the most common field type used in test fixtures. +pub(crate) fn string_ty() -> SchemaType { + SchemaType::Scalar(SchemaScalar::String) +} + +pub(crate) fn path_field<'a>(name: &str, ty: &'a SchemaType) -> PlannedRequestField<'a> { + PlannedRequestField { + name: name.into(), + optional: false, + ty, + kind: RequestFieldKind::Path, + } +} + +pub(crate) fn query_field<'a>( + name: &str, + optional: bool, + ty: &'a SchemaType, +) -> PlannedRequestField<'a> { + PlannedRequestField { + name: name.into(), + optional, + ty, + kind: RequestFieldKind::Query, + } +} + +/// Build a `PlannedRequestField` of kind `Body` for tests that exercise the +/// FlatJson body layout (inline JSON object body whose properties hoisted to +/// top-level). +pub(crate) fn body_field<'a>( + name: &str, + optional: bool, + ty: &'a SchemaType, +) -> PlannedRequestField<'a> { + PlannedRequestField { + name: name.into(), + optional, + ty, + kind: RequestFieldKind::Body, + } +} + +/// A `PlannedRequestBody::Nested` carrier with the given `ty` and optionality. +pub(crate) fn nested_body(ty: &SchemaType, optional: bool) -> PlannedRequestBody<'_> { + PlannedRequestBody::Nested { ty, optional } +} + +/// A `PlannedRequestBody::FlatJson` carrier whose properties are the given +/// `Body`-kinded fields. `required` records whether the envelope was +/// `requestBody.required: true`. +pub(crate) fn flat_json_body<'a>( + properties: Vec>, + required: bool, +) -> PlannedRequestBody<'a> { + PlannedRequestBody::FlatJson { + properties, + required, + } +} + +/// Returns a `PlannedRequestContract` with no fields, headers, or body. +/// Useful in tests that care about operation structure but not request shape. +pub(crate) fn empty_request() -> PlannedRequestContract<'static> { + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: None, + } +} + +/// Constructs a minimal `PlannedOperation` with the given parameters. +/// `response` is `None` for operations without a typed response. +pub(crate) fn op_with<'a>( + operation_id: &str, + method: HttpMethod, + path: &str, + request: PlannedRequestContract<'a>, + response: Option<&'a ResponseContent>, +) -> PlannedOperation<'a> { + PlannedOperation { + operation_id: operation_id.to_string(), + method_name: operation_id.to_string(), + method, + path: path.to_string(), + request, + response, + errors: &[], + description: None, + deprecated: false, + } +} + +/// Variant of `op_with` that attaches an error-response slice. Borrows +/// the slice from the caller — typical use is `&[ErrorResponse{...}, +/// ...]` constructed in the test body. +pub(crate) fn op_with_errors<'a>( + operation_id: &str, + errors: &'a [crate::ir::canonical::ErrorResponse], +) -> PlannedOperation<'a> { + PlannedOperation { + operation_id: operation_id.to_string(), + method_name: operation_id.to_string(), + method: HttpMethod::Post, + path: "/x".to_string(), + request: empty_request(), + response: None, + errors, + description: None, + deprecated: false, + } +} + +fn build_form_fields<'a>( + fields: Vec<(&str, bool, &'a BodyFieldType)>, +) -> Vec> { + fields + .into_iter() + .map(|(name, optional, ty)| PlannedFormField { + name: name.into(), + optional, + ty, + }) + .collect() +} + +/// Constructs a `PlannedOperation` whose request body is a multipart form, +/// populated with the supplied form fields. `fields` and `headers` on the +/// contract are empty. Each tuple is `(name, optional, ty)` where `ty` is +/// borrowed from the caller (matching the IR-borrowing convention of +/// `PlannedFormField`). +pub(crate) fn op_with_multipart_fields<'a>( + fields: Vec<(&str, bool, &'a BodyFieldType)>, +) -> PlannedOperation<'a> { + op_with( + "op", + HttpMethod::Post, + "/op", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(PlannedRequestBody::Multipart { + fields: build_form_fields(fields), + }), + }, + None, + ) +} + +/// Constructs a `PlannedOperation` whose request body is a multipart form +/// alongside non-empty `path/query` fields and/or `headers`. Mirror of +/// `op_with_multipart_fields` but lets a caller supply path/query fields +/// (typically `path_field(...)` / `query_field(...)`) and a list of +/// `PlannedHeader`s in addition to the form fields. +pub(crate) fn op_with_multipart_fields_full<'a>( + path_fields: Vec>, + headers: Vec>, + form_fields: Vec<(&str, bool, &'a BodyFieldType)>, +) -> PlannedOperation<'a> { + op_with( + "op", + HttpMethod::Post, + "/op", + PlannedRequestContract { + fields: path_fields, + headers, + body: Some(PlannedRequestBody::Multipart { + fields: build_form_fields(form_fields), + }), + }, + None, + ) +} + +/// Constructs a `PlannedOperation` whose request body is a url-encoded form, +/// populated with the supplied form fields. Mirror of +/// `op_with_multipart_fields` for the urlencoded variant. +pub(crate) fn op_with_urlencoded_fields<'a>( + fields: Vec<(&str, bool, &'a BodyFieldType)>, +) -> PlannedOperation<'a> { + op_with( + "op", + HttpMethod::Post, + "/op", + PlannedRequestContract { + fields: vec![], + headers: vec![], + body: Some(PlannedRequestBody::UrlEncoded { + fields: build_form_fields(fields), + }), + }, + None, + ) +} diff --git a/templates/angular/rest.model.ts b/templates/angular/rest.model.ts new file mode 100644 index 0000000..3167af7 --- /dev/null +++ b/templates/angular/rest.model.ts @@ -0,0 +1,78 @@ +import type { + HttpContext, + HttpHeaders, + HttpParams, + HttpResourceOptions, + HttpResourceRequest, +} from '@angular/common/http'; + +export type QueryParamValue = + | string + | number + | boolean + | ReadonlyArray; + +export interface CommonRequest extends Pick< + HttpResourceRequest, + 'body' | 'params' | 'headers' +> { + method: string; + url: string; + body?: unknown; + params?: HttpParams | Record; + headers?: HttpHeaders | Record; +} + +export interface WithDefault { + defaultValue: NoInfer; +} + +export interface WithParse { + parse: (raw: TRaw) => TResult; +} + +export type BaseHttpResourceOptions = Omit< + HttpResourceOptions, + 'parse' | 'defaultValue' +>; + +export type BaseHttpResourceOptionsWithParse = BaseHttpResourceOptions< + TResult, + TRaw +> & + WithParse; + +export type BaseHttpResourceOptionsWithDefault< + TResult, + TRaw = TResult, +> = BaseHttpResourceOptions & WithDefault; + +export type BaseHttpResourceOptionsWithDefaultAndParse = + BaseHttpResourceOptions & + WithParse & + WithDefault; + +export type HttpResourceOptionsUnion = + | BaseHttpResourceOptions + | BaseHttpResourceOptionsWithParse + | BaseHttpResourceOptionsWithDefault + | BaseHttpResourceOptionsWithDefaultAndParse; + +// Omits body/params/headers (generator supplies them) and responseType +// (fixed per requestFactory variant). Structurally compatible with +// HttpClient.request(method, url, options) so the runtime spread doesn't +// need a Parameters<…>[2] cast. +export type ObservableOptions = { + context?: HttpContext; + observe?: 'body' | 'response' | 'events'; + reportProgress?: boolean; + transferCache?: { includeHeaders?: string[] } | boolean; + withCredentials?: boolean; + keepalive?: boolean; + redirect?: RequestRedirect; + mode?: RequestMode; + credentials?: RequestCredentials; + priority?: RequestPriority; + cache?: RequestCache; + timeout?: number; +}; diff --git a/templates/angular/rest.util.ts b/templates/angular/rest.util.ts new file mode 100644 index 0000000..cff7e5d --- /dev/null +++ b/templates/angular/rest.util.ts @@ -0,0 +1,318 @@ +import { HttpClient, HttpParams, httpResource } from '@angular/common/http'; +import type { + HttpEvent, + HttpResourceOptions, + HttpResourceRef, + HttpResponse, +} from '@angular/common/http'; +import { + InjectionToken, + inject, + makeEnvironmentProviders, + type EnvironmentProviders, +} from '@angular/core'; +import type { Observable } from 'rxjs'; +import type { + BaseHttpResourceOptionsWithDefault, + BaseHttpResourceOptionsWithDefaultAndParse, + BaseHttpResourceOptionsWithParse, + CommonRequest, + HttpResourceOptionsUnion, + ObservableOptions, + QueryParamValue, +} from './rest.model'; + +type QueryParamRecord = Record; +type ResponseType = 'blob' | 'text' | 'arraybuffer'; + +export const OPENAPI_NG_BASE_PATH = new InjectionToken('OPENAPI_NG_BASE_PATH'); + +export function provideOpenapiNg(config: { basePath: string }): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: OPENAPI_NG_BASE_PATH, useValue: config.basePath }, + ]); +} + +export function httpParams(params: QueryParamRecord): HttpParams { + let resolved = new HttpParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + if (Array.isArray(value)) { + for (const item of value) { + resolved = resolved.append(key, String(item)); + } + } else { + resolved = resolved.set(key, String(value)); + } + } + } + return resolved; +} + +export interface RequestFnValue { + request(request: Request): CommonRequest; + observable( + request: Request, + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + request: Request, + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(request: Request, options?: ObservableOptions): Observable; + resource( + reactiveReq: () => Request | undefined, + options: BaseHttpResourceOptionsWithDefault, + ): HttpResourceRef; + resource( + reactiveReq: () => Request | undefined, + options: BaseHttpResourceOptionsWithDefaultAndParse, + ): HttpResourceRef; + resource( + reactiveReq: () => Request | undefined, + options?: HttpResourceOptionsUnion, + ): HttpResourceRef; + resource( + reactiveReq: () => Request | undefined, + options: BaseHttpResourceOptionsWithParse, + ): HttpResourceRef; +} + +export interface RequestFnVoid { + request(request: Request): CommonRequest; + observable( + request: Request, + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + request: Request, + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(request: Request, options?: ObservableOptions): Observable; + resource( + reactiveReq: () => Request | undefined, + options?: HttpResourceOptionsUnion, + ): HttpResourceRef; +} + +export type RequestFn = [Response] extends [void] + ? RequestFnVoid + : RequestFnValue; + +export interface ZeroArgRequestFnValue { + request(): CommonRequest; + observable( + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(options?: ObservableOptions): Observable; + resource( + options: BaseHttpResourceOptionsWithDefault, + ): HttpResourceRef; + resource( + options: BaseHttpResourceOptionsWithDefaultAndParse, + ): HttpResourceRef; + resource( + options?: HttpResourceOptionsUnion, + ): HttpResourceRef; + resource( + options: BaseHttpResourceOptionsWithParse, + ): HttpResourceRef; +} + +export interface ZeroArgRequestFnVoid { + request(): CommonRequest; + observable( + options: ObservableOptions & { observe: 'response' }, + ): Observable>; + observable( + options: ObservableOptions & { observe: 'events' }, + ): Observable>; + observable(options?: ObservableOptions): Observable; + resource(options?: HttpResourceOptionsUnion): HttpResourceRef; +} + +export type ZeroArgRequestFn = [Response] extends [void] + ? ZeroArgRequestFnVoid + : ZeroArgRequestFnValue; + +function joinBasePath(base: string, url: string): string { + // Absolute URLs (https://…, etc.) bypass the configured basePath. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return url; + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; + const normalizedUrl = url.startsWith('/') ? url : `/${url}`; + return normalizedBase + normalizedUrl; +} + +function withBasePath( + reqFn: (...args: TArgs) => CommonRequest, +): (...args: TArgs) => CommonRequest { + const basePath = inject(OPENAPI_NG_BASE_PATH, { optional: true }); + if (!basePath) return reqFn; + return (...args: TArgs) => { + const common = reqFn(...args); + return { ...common, url: joinBasePath(basePath, common.url) }; + }; +} + +type ObserveFn = ( + request: CommonRequest, + options?: ObservableOptions, +) => Observable; + +function makeObserveFn( + http: HttpClient, + responseType?: ResponseType, +): ObserveFn { + return (request, options) => { + const merged = { + ...options, + body: request.body, + headers: request.headers, + params: request.params, + ...(responseType ? { responseType } : {}), + }; + return http.request(request.method, request.url, merged) as Observable; + }; +} + +function makeRequestFn( + reqFn: (req: Request) => CommonRequest, + observe: ObserveFn, + resourceImpl: ( + request: () => CommonRequest | undefined, + options?: HttpResourceOptions, + ) => HttpResourceRef, +): RequestFn { + const wrappedReqFn = withBasePath(reqFn); + return { + request: (req: Request): CommonRequest => wrappedReqFn(req), + observable: (req: Request, options?: ObservableOptions): Observable => + observe(wrappedReqFn(req), options), + resource: ( + reactiveReq: () => Request | undefined, + options?: HttpResourceOptionsUnion, + ): HttpResourceRef => + resourceImpl(() => { + const request = reactiveReq(); + return request === undefined ? undefined : wrappedReqFn(request); + }, options), + } as RequestFn; +} + +function makeZeroArgRequestFn( + reqFn: () => CommonRequest, + observe: ObserveFn, + resourceImpl: ( + request: () => CommonRequest | undefined, + options?: HttpResourceOptions, + ) => HttpResourceRef, +): ZeroArgRequestFn { + const wrappedReqFn = withBasePath(reqFn); + return { + request: (): CommonRequest => wrappedReqFn(), + observable: (options?: ObservableOptions): Observable => + observe(wrappedReqFn(), options), + resource: ( + options?: HttpResourceOptionsUnion, + ): HttpResourceRef => + resourceImpl(() => wrappedReqFn(), options), + } as ZeroArgRequestFn; +} + +function makeJsonRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http), + (request, options) => httpResource(request, options), + ); +} + +function makeJsonZeroArg( + reqFn: () => CommonRequest, +): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http), + (request, options) => httpResource(request, options), + ); +} + +function makeBlobRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http, 'blob'), + (request, options) => httpResource.blob(request, options), + ); +} + +function makeBlobZeroArg(reqFn: () => CommonRequest): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http, 'blob'), + (request, options) => httpResource.blob(request, options), + ); +} + +function makeTextRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http, 'text'), + (request, options) => httpResource.text(request, options), + ); +} + +function makeTextZeroArg(reqFn: () => CommonRequest): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http, 'text'), + (request, options) => httpResource.text(request, options), + ); +} + +function makeArrayBufferRequestFn( + reqFn: (request: Request) => CommonRequest, +): RequestFn { + const http = inject(HttpClient); + return makeRequestFn( + reqFn, + makeObserveFn(http, 'arraybuffer'), + (request, options) => httpResource.arrayBuffer(request, options), + ); +} + +function makeArrayBufferZeroArg( + reqFn: () => CommonRequest, +): ZeroArgRequestFn { + const http = inject(HttpClient); + return makeZeroArgRequestFn( + reqFn, + makeObserveFn(http, 'arraybuffer'), + (request, options) => httpResource.arrayBuffer(request, options), + ); +} + +export const requestFactory = Object.assign(makeJsonRequestFn, { + blob: makeBlobRequestFn, + text: makeTextRequestFn, + arrayBuffer: makeArrayBufferRequestFn, + zeroArg: Object.assign(makeJsonZeroArg, { + blob: makeBlobZeroArg, + text: makeTextZeroArg, + arrayBuffer: makeArrayBufferZeroArg, + }), +}); diff --git a/test/fixtures/additional-properties-boolean.openapi.yaml b/test/fixtures/additional-properties-boolean.openapi.yaml new file mode 100644 index 0000000..2372c99 --- /dev/null +++ b/test/fixtures/additional-properties-boolean.openapi.yaml @@ -0,0 +1,13 @@ +openapi: 3.0.3 +info: + title: Additional Properties Boolean + version: 1.0.0 +paths: {} +components: + schemas: + PermissiveBag: + type: object + additionalProperties: true + properties: + id: + type: string diff --git a/test/fixtures/additional-properties-false.openapi.yaml b/test/fixtures/additional-properties-false.openapi.yaml new file mode 100644 index 0000000..b9f1480 --- /dev/null +++ b/test/fixtures/additional-properties-false.openapi.yaml @@ -0,0 +1,21 @@ +# Pins the generator's handling of `additionalProperties: false`. +# OpenAPI semantics: "no extras beyond the declared `properties`" — +# structurally the same as omitting the field for our emit (TypeScript +# interfaces with declared properties don't accept arbitrary extras by +# default). The `true` form remains rejected as semantically unsupported +# (see additional-properties-boolean.openapi.yaml). +openapi: 3.0.3 +info: + title: Additional Properties False + version: 1.0.0 +paths: {} +components: + schemas: + StrictBag: + type: object + additionalProperties: false + required: + - id + properties: + id: + type: string diff --git a/test/fixtures/additional-properties.openapi.yaml b/test/fixtures/additional-properties.openapi.yaml new file mode 100644 index 0000000..a902c8a --- /dev/null +++ b/test/fixtures/additional-properties.openapi.yaml @@ -0,0 +1,71 @@ +openapi: 3.0.3 +info: + title: Petstore Additional Properties + version: 1.0.0 +paths: + /pets: + get: + operationId: listPetCatalog + tags: + - Pet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/PetCatalog' +components: + schemas: + PetCatalog: + type: object + required: + - scope + - petsByBreed + properties: + scope: + type: string + enum: + - available + - adopted + - foster + petsByBreed: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/Pet' + Pet: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + PetMetadataByTag: + type: object + additionalProperties: + $ref: '#/components/schemas/PetMetadata' + Tag: + type: object + required: + - id + - label + properties: + id: + type: integer + label: + type: string + PetMetadata: + type: object + required: + - spotlight + - tag + properties: + spotlight: + type: boolean + tag: + $ref: '#/components/schemas/Tag' diff --git a/test/fixtures/allof-composition.openapi.yaml b/test/fixtures/allof-composition.openapi.yaml new file mode 100644 index 0000000..adff1fe --- /dev/null +++ b/test/fixtures/allof-composition.openapi.yaml @@ -0,0 +1,63 @@ +openapi: 3.0.3 +info: + title: AllOf Composition + version: 1.0.0 +paths: + /adopters/{adopterId}: + get: + operationId: getAdopterProfile + tags: + - Adopter + parameters: + - name: adopterId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/AdopterProfile' +components: + schemas: + AuditFields: + type: object + required: + - createdAt + properties: + createdAt: + type: string + format: date-time + archivedAt: + type: string + format: date-time + nullable: true + ContactFields: + type: object + required: + - email + properties: + email: + type: string + format: email + phone: + type: string + AdopterProfile: + allOf: + - $ref: '#/components/schemas/AuditFields' + - $ref: '#/components/schemas/ContactFields' + - type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + nickname: + type: string + nullable: true diff --git a/test/fixtures/anchor-fanout.openapi.yaml b/test/fixtures/anchor-fanout.openapi.yaml new file mode 100644 index 0000000..ac5f63f --- /dev/null +++ b/test/fixtures/anchor-fanout.openapi.yaml @@ -0,0 +1,528 @@ +openapi: 3.0.3 +info: { title: anchor-fanout, version: '1.0.0' } +paths: {} +components: + schemas: + Base: &b + type: object + properties: + prop_00: { type: string, description: 'property 00 padding text' } + prop_01: { type: string, description: 'property 01 padding text' } + prop_02: { type: string, description: 'property 02 padding text' } + prop_03: { type: string, description: 'property 03 padding text' } + prop_04: { type: string, description: 'property 04 padding text' } + prop_05: { type: string, description: 'property 05 padding text' } + prop_06: { type: string, description: 'property 06 padding text' } + prop_07: { type: string, description: 'property 07 padding text' } + prop_08: { type: string, description: 'property 08 padding text' } + prop_09: { type: string, description: 'property 09 padding text' } + prop_10: { type: string, description: 'property 10 padding text' } + prop_11: { type: string, description: 'property 11 padding text' } + prop_12: { type: string, description: 'property 12 padding text' } + prop_13: { type: string, description: 'property 13 padding text' } + prop_14: { type: string, description: 'property 14 padding text' } + prop_15: { type: string, description: 'property 15 padding text' } + prop_16: { type: string, description: 'property 16 padding text' } + prop_17: { type: string, description: 'property 17 padding text' } + prop_18: { type: string, description: 'property 18 padding text' } + prop_19: { type: string, description: 'property 19 padding text' } + A0000: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0001: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0002: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0003: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0004: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0005: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0006: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0007: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0008: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0009: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0010: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0011: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0012: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0013: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0014: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0015: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0016: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0017: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0018: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0019: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0020: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0021: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0022: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0023: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0024: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0025: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0026: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0027: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0028: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0029: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0030: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0031: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0032: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0033: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0034: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0035: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0036: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0037: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0038: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0039: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0040: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0041: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0042: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0043: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0044: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0045: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0046: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0047: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0048: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0049: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0050: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0051: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0052: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0053: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0054: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0055: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0056: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0057: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0058: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0059: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0060: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0061: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0062: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0063: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0064: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0065: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0066: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0067: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0068: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0069: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0070: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0071: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0072: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0073: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0074: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0075: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0076: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0077: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0078: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0079: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0080: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0081: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0082: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0083: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0084: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0085: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0086: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0087: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0088: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0089: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0090: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0091: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0092: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0093: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0094: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0095: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0096: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0097: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0098: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0099: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0100: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0101: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0102: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0103: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0104: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0105: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0106: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0107: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0108: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0109: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0110: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0111: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0112: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0113: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0114: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0115: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0116: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0117: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0118: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0119: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0120: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0121: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0122: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0123: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0124: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0125: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0126: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0127: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0128: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0129: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0130: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0131: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0132: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0133: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0134: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0135: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0136: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0137: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0138: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0139: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0140: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0141: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0142: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0143: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0144: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0145: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0146: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0147: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0148: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0149: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0150: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0151: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0152: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0153: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0154: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0155: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0156: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0157: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0158: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0159: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0160: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0161: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0162: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0163: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0164: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0165: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0166: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0167: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0168: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0169: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0170: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0171: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0172: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0173: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0174: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0175: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0176: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0177: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0178: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0179: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0180: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0181: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0182: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0183: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0184: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0185: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0186: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0187: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0188: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0189: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0190: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0191: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0192: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0193: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0194: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0195: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0196: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0197: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0198: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0199: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0200: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0201: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0202: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0203: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0204: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0205: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0206: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0207: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0208: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0209: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0210: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0211: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0212: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0213: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0214: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0215: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0216: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0217: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0218: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0219: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0220: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0221: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0222: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0223: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0224: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0225: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0226: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0227: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0228: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0229: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0230: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0231: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0232: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0233: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0234: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0235: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0236: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0237: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0238: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0239: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0240: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0241: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0242: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0243: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0244: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0245: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0246: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0247: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0248: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0249: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0250: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0251: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0252: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0253: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0254: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0255: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0256: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0257: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0258: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0259: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0260: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0261: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0262: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0263: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0264: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0265: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0266: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0267: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0268: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0269: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0270: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0271: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0272: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0273: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0274: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0275: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0276: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0277: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0278: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0279: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0280: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0281: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0282: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0283: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0284: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0285: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0286: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0287: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0288: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0289: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0290: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0291: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0292: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0293: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0294: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0295: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0296: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0297: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0298: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0299: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0300: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0301: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0302: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0303: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0304: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0305: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0306: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0307: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0308: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0309: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0310: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0311: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0312: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0313: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0314: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0315: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0316: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0317: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0318: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0319: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0320: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0321: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0322: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0323: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0324: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0325: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0326: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0327: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0328: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0329: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0330: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0331: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0332: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0333: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0334: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0335: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0336: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0337: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0338: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0339: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0340: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0341: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0342: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0343: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0344: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0345: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0346: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0347: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0348: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0349: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0350: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0351: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0352: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0353: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0354: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0355: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0356: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0357: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0358: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0359: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0360: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0361: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0362: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0363: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0364: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0365: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0366: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0367: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0368: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0369: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0370: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0371: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0372: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0373: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0374: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0375: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0376: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0377: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0378: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0379: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0380: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0381: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0382: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0383: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0384: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0385: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0386: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0387: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0388: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0389: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0390: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0391: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0392: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0393: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0394: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0395: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0396: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0397: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0398: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0399: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0400: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0401: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0402: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0403: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0404: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0405: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0406: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0407: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0408: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0409: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0410: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0411: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0412: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0413: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0414: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0415: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0416: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0417: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0418: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0419: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0420: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0421: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0422: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0423: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0424: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0425: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0426: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0427: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0428: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0429: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0430: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0431: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0432: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0433: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0434: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0435: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0436: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0437: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0438: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0439: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0440: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0441: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0442: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0443: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0444: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0445: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0446: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0447: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0448: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0449: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0450: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0451: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0452: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0453: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0454: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0455: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0456: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0457: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0458: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0459: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0460: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0461: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0462: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0463: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0464: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0465: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0466: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0467: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0468: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0469: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0470: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0471: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0472: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0473: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0474: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0475: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0476: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0477: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0478: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0479: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0480: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0481: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0482: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0483: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0484: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0485: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0486: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0487: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0488: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0489: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0490: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0491: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0492: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0493: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0494: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0495: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0496: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0497: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0498: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } + A0499: { allOf: [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b, *b] } diff --git a/test/fixtures/anchor-modest.openapi.yaml b/test/fixtures/anchor-modest.openapi.yaml new file mode 100644 index 0000000..0620651 --- /dev/null +++ b/test/fixtures/anchor-modest.openapi.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: anchor-modest + version: '1.0.0' +# Boundary fixture for the OPENAPI_NG_MAX_EXPANSION_RATIO guard. A small +# anchor (Audit) is aliased 3 times across nested mappings — the kind of +# legitimate anchor reuse hand-written specs commonly use. The +# re-serialised byte ratio stays well under 50×, so this fixture +# exercises the "accept" side of the cap. Pins that modest anchor use is +# not regressed by the anchor-fanout policy. +paths: {} +components: + schemas: + Audit: &audit + type: object + properties: + createdAt: { type: string } + updatedAt: { type: string } + Pet: + allOf: + - *audit + - type: object + properties: + id: { type: string } + name: { type: string } + Owner: + allOf: + - *audit + - type: object + properties: + id: { type: string } + displayName: { type: string } + Visit: + allOf: + - *audit + - type: object + properties: + id: { type: string } + notes: { type: string } diff --git a/test/fixtures/bench-large.openapi.yaml b/test/fixtures/bench-large.openapi.yaml new file mode 100644 index 0000000..fe77c22 --- /dev/null +++ b/test/fixtures/bench-large.openapi.yaml @@ -0,0 +1,1897 @@ +openapi: 3.0.3 +info: + title: Bench Large + version: 1.0.0 +paths: + /resource1: + get: + operationId: getResource1 + tags: + - Resource1 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource1List' + post: + operationId: createResource1 + tags: + - Resource1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource1' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource1' + /resource2: + get: + operationId: getResource2 + tags: + - Resource1 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource2List' + post: + operationId: createResource2 + tags: + - Resource1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource2' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource2' + /resource3: + get: + operationId: getResource3 + tags: + - Resource1 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource3List' + post: + operationId: createResource3 + tags: + - Resource1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource3' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource3' + /resource4: + get: + operationId: getResource4 + tags: + - Resource1 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource4List' + post: + operationId: createResource4 + tags: + - Resource1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource4' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource4' + /resource5: + get: + operationId: getResource5 + tags: + - Resource1 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource5List' + post: + operationId: createResource5 + tags: + - Resource1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource5' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource5' + /resource6: + get: + operationId: getResource6 + tags: + - Resource2 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource6List' + post: + operationId: createResource6 + tags: + - Resource2 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource6' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource6' + /resource7: + get: + operationId: getResource7 + tags: + - Resource2 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource7List' + post: + operationId: createResource7 + tags: + - Resource2 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource7' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource7' + /resource8: + get: + operationId: getResource8 + tags: + - Resource2 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource8List' + post: + operationId: createResource8 + tags: + - Resource2 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource8' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource8' + /resource9: + get: + operationId: getResource9 + tags: + - Resource2 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource9List' + post: + operationId: createResource9 + tags: + - Resource2 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource9' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource9' + /resource10: + get: + operationId: getResource10 + tags: + - Resource2 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource10List' + post: + operationId: createResource10 + tags: + - Resource2 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource10' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource10' + /resource11: + get: + operationId: getResource11 + tags: + - Resource3 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource11List' + post: + operationId: createResource11 + tags: + - Resource3 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource11' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource11' + /resource12: + get: + operationId: getResource12 + tags: + - Resource3 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource12List' + post: + operationId: createResource12 + tags: + - Resource3 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource12' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource12' + /resource13: + get: + operationId: getResource13 + tags: + - Resource3 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource13List' + post: + operationId: createResource13 + tags: + - Resource3 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource13' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource13' + /resource14: + get: + operationId: getResource14 + tags: + - Resource3 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource14List' + post: + operationId: createResource14 + tags: + - Resource3 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource14' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource14' + /resource15: + get: + operationId: getResource15 + tags: + - Resource3 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource15List' + post: + operationId: createResource15 + tags: + - Resource3 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource15' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource15' + /resource16: + get: + operationId: getResource16 + tags: + - Resource4 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource16List' + post: + operationId: createResource16 + tags: + - Resource4 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource16' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource16' + /resource17: + get: + operationId: getResource17 + tags: + - Resource4 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource17List' + post: + operationId: createResource17 + tags: + - Resource4 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource17' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource17' + /resource18: + get: + operationId: getResource18 + tags: + - Resource4 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource18List' + post: + operationId: createResource18 + tags: + - Resource4 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource18' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource18' + /resource19: + get: + operationId: getResource19 + tags: + - Resource4 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource19List' + post: + operationId: createResource19 + tags: + - Resource4 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource19' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource19' + /resource20: + get: + operationId: getResource20 + tags: + - Resource4 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource20List' + post: + operationId: createResource20 + tags: + - Resource4 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource20' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource20' + /resource21: + get: + operationId: getResource21 + tags: + - Resource5 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource21List' + post: + operationId: createResource21 + tags: + - Resource5 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource21' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource21' + /resource22: + get: + operationId: getResource22 + tags: + - Resource5 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource22List' + post: + operationId: createResource22 + tags: + - Resource5 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource22' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource22' + /resource23: + get: + operationId: getResource23 + tags: + - Resource5 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource23List' + post: + operationId: createResource23 + tags: + - Resource5 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource23' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource23' + /resource24: + get: + operationId: getResource24 + tags: + - Resource5 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource24List' + post: + operationId: createResource24 + tags: + - Resource5 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource24' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource24' + /resource25: + get: + operationId: getResource25 + tags: + - Resource5 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource25List' + post: + operationId: createResource25 + tags: + - Resource5 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource25' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource25' + /resource26: + get: + operationId: getResource26 + tags: + - Resource6 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource26List' + post: + operationId: createResource26 + tags: + - Resource6 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource26' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource26' + /resource27: + get: + operationId: getResource27 + tags: + - Resource6 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource27List' + post: + operationId: createResource27 + tags: + - Resource6 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource27' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource27' + /resource28: + get: + operationId: getResource28 + tags: + - Resource6 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource28List' + post: + operationId: createResource28 + tags: + - Resource6 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource28' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource28' + /resource29: + get: + operationId: getResource29 + tags: + - Resource6 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource29List' + post: + operationId: createResource29 + tags: + - Resource6 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource29' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource29' + /resource30: + get: + operationId: getResource30 + tags: + - Resource6 + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource30List' + post: + operationId: createResource30 + tags: + - Resource6 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Resource30' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Resource30' +components: + schemas: + Resource1: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource1Status' + Resource1Status: + type: string + enum: + - active + - archived + - pending + Resource1List: + type: array + items: + $ref: '#/components/schemas/Resource1' + Resource2: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource2Status' + Resource2Status: + type: string + enum: + - active + - archived + - pending + Resource2List: + type: array + items: + $ref: '#/components/schemas/Resource2' + Resource3: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource3Status' + Resource3Status: + type: string + enum: + - active + - archived + - pending + Resource3List: + type: array + items: + $ref: '#/components/schemas/Resource3' + Resource4: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource4Status' + Resource4Status: + type: string + enum: + - active + - archived + - pending + Resource4List: + type: array + items: + $ref: '#/components/schemas/Resource4' + Resource5: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource5Status' + Resource5Status: + type: string + enum: + - active + - archived + - pending + Resource5List: + type: array + items: + $ref: '#/components/schemas/Resource5' + Resource6: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource6Status' + Resource6Status: + type: string + enum: + - active + - archived + - pending + Resource6List: + type: array + items: + $ref: '#/components/schemas/Resource6' + Resource7: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource7Status' + Resource7Status: + type: string + enum: + - active + - archived + - pending + Resource7List: + type: array + items: + $ref: '#/components/schemas/Resource7' + Resource8: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource8Status' + Resource8Status: + type: string + enum: + - active + - archived + - pending + Resource8List: + type: array + items: + $ref: '#/components/schemas/Resource8' + Resource9: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource9Status' + Resource9Status: + type: string + enum: + - active + - archived + - pending + Resource9List: + type: array + items: + $ref: '#/components/schemas/Resource9' + Resource10: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource10Status' + Resource10Status: + type: string + enum: + - active + - archived + - pending + Resource10List: + type: array + items: + $ref: '#/components/schemas/Resource10' + Resource11: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource11Status' + Resource11Status: + type: string + enum: + - active + - archived + - pending + Resource11List: + type: array + items: + $ref: '#/components/schemas/Resource11' + Resource12: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource12Status' + Resource12Status: + type: string + enum: + - active + - archived + - pending + Resource12List: + type: array + items: + $ref: '#/components/schemas/Resource12' + Resource13: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource13Status' + Resource13Status: + type: string + enum: + - active + - archived + - pending + Resource13List: + type: array + items: + $ref: '#/components/schemas/Resource13' + Resource14: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource14Status' + Resource14Status: + type: string + enum: + - active + - archived + - pending + Resource14List: + type: array + items: + $ref: '#/components/schemas/Resource14' + Resource15: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource15Status' + Resource15Status: + type: string + enum: + - active + - archived + - pending + Resource15List: + type: array + items: + $ref: '#/components/schemas/Resource15' + Resource16: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource16Status' + Resource16Status: + type: string + enum: + - active + - archived + - pending + Resource16List: + type: array + items: + $ref: '#/components/schemas/Resource16' + Resource17: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource17Status' + Resource17Status: + type: string + enum: + - active + - archived + - pending + Resource17List: + type: array + items: + $ref: '#/components/schemas/Resource17' + Resource18: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource18Status' + Resource18Status: + type: string + enum: + - active + - archived + - pending + Resource18List: + type: array + items: + $ref: '#/components/schemas/Resource18' + Resource19: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource19Status' + Resource19Status: + type: string + enum: + - active + - archived + - pending + Resource19List: + type: array + items: + $ref: '#/components/schemas/Resource19' + Resource20: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource20Status' + Resource20Status: + type: string + enum: + - active + - archived + - pending + Resource20List: + type: array + items: + $ref: '#/components/schemas/Resource20' + Resource21: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource21Status' + Resource21Status: + type: string + enum: + - active + - archived + - pending + Resource21List: + type: array + items: + $ref: '#/components/schemas/Resource21' + Resource22: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource22Status' + Resource22Status: + type: string + enum: + - active + - archived + - pending + Resource22List: + type: array + items: + $ref: '#/components/schemas/Resource22' + Resource23: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource23Status' + Resource23Status: + type: string + enum: + - active + - archived + - pending + Resource23List: + type: array + items: + $ref: '#/components/schemas/Resource23' + Resource24: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource24Status' + Resource24Status: + type: string + enum: + - active + - archived + - pending + Resource24List: + type: array + items: + $ref: '#/components/schemas/Resource24' + Resource25: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource25Status' + Resource25Status: + type: string + enum: + - active + - archived + - pending + Resource25List: + type: array + items: + $ref: '#/components/schemas/Resource25' + Resource26: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource26Status' + Resource26Status: + type: string + enum: + - active + - archived + - pending + Resource26List: + type: array + items: + $ref: '#/components/schemas/Resource26' + Resource27: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource27Status' + Resource27Status: + type: string + enum: + - active + - archived + - pending + Resource27List: + type: array + items: + $ref: '#/components/schemas/Resource27' + Resource28: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource28Status' + Resource28Status: + type: string + enum: + - active + - archived + - pending + Resource28List: + type: array + items: + $ref: '#/components/schemas/Resource28' + Resource29: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource29Status' + Resource29Status: + type: string + enum: + - active + - archived + - pending + Resource29List: + type: array + items: + $ref: '#/components/schemas/Resource29' + Resource30: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + tags: + type: array + items: + type: string + status: + $ref: '#/components/schemas/Resource30Status' + Resource30Status: + type: string + enum: + - active + - archived + - pending + Resource30List: + type: array + items: + $ref: '#/components/schemas/Resource30' diff --git a/test/fixtures/bench-multi-tag.openapi.yaml b/test/fixtures/bench-multi-tag.openapi.yaml new file mode 100644 index 0000000..a24041a --- /dev/null +++ b/test/fixtures/bench-multi-tag.openapi.yaml @@ -0,0 +1,2757 @@ +openapi: 3.0.3 +info: + title: Multi-Tag Bench + version: 1.0.0 +paths: + /catalog/1: + get: + operationId: getCatalogItem1 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem1List' + post: + operationId: createCatalogItem1 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem1' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem1' + /catalog/2: + get: + operationId: getCatalogItem2 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem2List' + post: + operationId: createCatalogItem2 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem2' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem2' + /catalog/3: + get: + operationId: getCatalogItem3 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem3List' + post: + operationId: createCatalogItem3 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem3' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem3' + /catalog/4: + get: + operationId: getCatalogItem4 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem4List' + post: + operationId: createCatalogItem4 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem4' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem4' + /catalog/5: + get: + operationId: getCatalogItem5 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem5List' + post: + operationId: createCatalogItem5 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem5' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem5' + /catalog/6: + get: + operationId: getCatalogItem6 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem6List' + post: + operationId: createCatalogItem6 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem6' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem6' + /catalog/7: + get: + operationId: getCatalogItem7 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem7List' + post: + operationId: createCatalogItem7 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem7' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem7' + /catalog/8: + get: + operationId: getCatalogItem8 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem8List' + post: + operationId: createCatalogItem8 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem8' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem8' + /catalog/9: + get: + operationId: getCatalogItem9 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem9List' + post: + operationId: createCatalogItem9 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem9' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem9' + /catalog/10: + get: + operationId: getCatalogItem10 + tags: + - Catalog + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem10List' + post: + operationId: createCatalogItem10 + tags: + - Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem10' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem10' + /inventory/1: + get: + operationId: getInventoryItem1 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem1List' + post: + operationId: createInventoryItem1 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem1' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem1' + /inventory/2: + get: + operationId: getInventoryItem2 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem2List' + post: + operationId: createInventoryItem2 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem2' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem2' + /inventory/3: + get: + operationId: getInventoryItem3 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem3List' + post: + operationId: createInventoryItem3 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem3' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem3' + /inventory/4: + get: + operationId: getInventoryItem4 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem4List' + post: + operationId: createInventoryItem4 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem4' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem4' + /inventory/5: + get: + operationId: getInventoryItem5 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem5List' + post: + operationId: createInventoryItem5 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem5' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem5' + /inventory/6: + get: + operationId: getInventoryItem6 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem6List' + post: + operationId: createInventoryItem6 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem6' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem6' + /inventory/7: + get: + operationId: getInventoryItem7 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem7List' + post: + operationId: createInventoryItem7 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem7' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem7' + /inventory/8: + get: + operationId: getInventoryItem8 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem8List' + post: + operationId: createInventoryItem8 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem8' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem8' + /inventory/9: + get: + operationId: getInventoryItem9 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem9List' + post: + operationId: createInventoryItem9 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem9' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem9' + /inventory/10: + get: + operationId: getInventoryItem10 + tags: + - Inventory + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem10List' + post: + operationId: createInventoryItem10 + tags: + - Inventory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem10' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryItem10' + /orders/1: + get: + operationId: getOrdersItem1 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem1List' + post: + operationId: createOrdersItem1 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem1' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem1' + /orders/2: + get: + operationId: getOrdersItem2 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem2List' + post: + operationId: createOrdersItem2 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem2' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem2' + /orders/3: + get: + operationId: getOrdersItem3 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem3List' + post: + operationId: createOrdersItem3 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem3' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem3' + /orders/4: + get: + operationId: getOrdersItem4 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem4List' + post: + operationId: createOrdersItem4 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem4' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem4' + /orders/5: + get: + operationId: getOrdersItem5 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem5List' + post: + operationId: createOrdersItem5 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem5' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem5' + /orders/6: + get: + operationId: getOrdersItem6 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem6List' + post: + operationId: createOrdersItem6 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem6' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem6' + /orders/7: + get: + operationId: getOrdersItem7 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem7List' + post: + operationId: createOrdersItem7 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem7' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem7' + /orders/8: + get: + operationId: getOrdersItem8 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem8List' + post: + operationId: createOrdersItem8 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem8' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem8' + /orders/9: + get: + operationId: getOrdersItem9 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem9List' + post: + operationId: createOrdersItem9 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem9' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem9' + /orders/10: + get: + operationId: getOrdersItem10 + tags: + - Orders + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem10List' + post: + operationId: createOrdersItem10 + tags: + - Orders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem10' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/OrdersItem10' + /customers/1: + get: + operationId: getCustomersItem1 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem1List' + post: + operationId: createCustomersItem1 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem1' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem1' + /customers/2: + get: + operationId: getCustomersItem2 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem2List' + post: + operationId: createCustomersItem2 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem2' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem2' + /customers/3: + get: + operationId: getCustomersItem3 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem3List' + post: + operationId: createCustomersItem3 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem3' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem3' + /customers/4: + get: + operationId: getCustomersItem4 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem4List' + post: + operationId: createCustomersItem4 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem4' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem4' + /customers/5: + get: + operationId: getCustomersItem5 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem5List' + post: + operationId: createCustomersItem5 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem5' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem5' + /customers/6: + get: + operationId: getCustomersItem6 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem6List' + post: + operationId: createCustomersItem6 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem6' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem6' + /customers/7: + get: + operationId: getCustomersItem7 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem7List' + post: + operationId: createCustomersItem7 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem7' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem7' + /customers/8: + get: + operationId: getCustomersItem8 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem8List' + post: + operationId: createCustomersItem8 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem8' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem8' + /customers/9: + get: + operationId: getCustomersItem9 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem9List' + post: + operationId: createCustomersItem9 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem9' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem9' + /customers/10: + get: + operationId: getCustomersItem10 + tags: + - Customers + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem10List' + post: + operationId: createCustomersItem10 + tags: + - Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem10' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/CustomersItem10' + /shipping/1: + get: + operationId: getShippingItem1 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem1List' + post: + operationId: createShippingItem1 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem1' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem1' + /shipping/2: + get: + operationId: getShippingItem2 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem2List' + post: + operationId: createShippingItem2 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem2' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem2' + /shipping/3: + get: + operationId: getShippingItem3 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem3List' + post: + operationId: createShippingItem3 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem3' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem3' + /shipping/4: + get: + operationId: getShippingItem4 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem4List' + post: + operationId: createShippingItem4 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem4' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem4' + /shipping/5: + get: + operationId: getShippingItem5 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem5List' + post: + operationId: createShippingItem5 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem5' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem5' + /shipping/6: + get: + operationId: getShippingItem6 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem6List' + post: + operationId: createShippingItem6 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem6' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem6' + /shipping/7: + get: + operationId: getShippingItem7 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem7List' + post: + operationId: createShippingItem7 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem7' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem7' + /shipping/8: + get: + operationId: getShippingItem8 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem8List' + post: + operationId: createShippingItem8 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem8' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem8' + /shipping/9: + get: + operationId: getShippingItem9 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem9List' + post: + operationId: createShippingItem9 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem9' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem9' + /shipping/10: + get: + operationId: getShippingItem10 + tags: + - Shipping + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem10List' + post: + operationId: createShippingItem10 + tags: + - Shipping + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem10' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingItem10' +components: + schemas: + CatalogItem1: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem1Status' + CatalogItem1Status: + type: string + enum: + - active + - inactive + CatalogItem1List: + type: array + items: + $ref: '#/components/schemas/CatalogItem1' + CatalogItem2: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem2Status' + CatalogItem2Status: + type: string + enum: + - active + - inactive + CatalogItem2List: + type: array + items: + $ref: '#/components/schemas/CatalogItem2' + CatalogItem3: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem3Status' + CatalogItem3Status: + type: string + enum: + - active + - inactive + CatalogItem3List: + type: array + items: + $ref: '#/components/schemas/CatalogItem3' + CatalogItem4: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem4Status' + CatalogItem4Status: + type: string + enum: + - active + - inactive + CatalogItem4List: + type: array + items: + $ref: '#/components/schemas/CatalogItem4' + CatalogItem5: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem5Status' + CatalogItem5Status: + type: string + enum: + - active + - inactive + CatalogItem5List: + type: array + items: + $ref: '#/components/schemas/CatalogItem5' + CatalogItem6: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem6Status' + CatalogItem6Status: + type: string + enum: + - active + - inactive + CatalogItem6List: + type: array + items: + $ref: '#/components/schemas/CatalogItem6' + CatalogItem7: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem7Status' + CatalogItem7Status: + type: string + enum: + - active + - inactive + CatalogItem7List: + type: array + items: + $ref: '#/components/schemas/CatalogItem7' + CatalogItem8: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem8Status' + CatalogItem8Status: + type: string + enum: + - active + - inactive + CatalogItem8List: + type: array + items: + $ref: '#/components/schemas/CatalogItem8' + CatalogItem9: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem9Status' + CatalogItem9Status: + type: string + enum: + - active + - inactive + CatalogItem9List: + type: array + items: + $ref: '#/components/schemas/CatalogItem9' + CatalogItem10: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CatalogItem10Status' + CatalogItem10Status: + type: string + enum: + - active + - inactive + CatalogItem10List: + type: array + items: + $ref: '#/components/schemas/CatalogItem10' + InventoryItem1: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem1Status' + InventoryItem1Status: + type: string + enum: + - active + - inactive + InventoryItem1List: + type: array + items: + $ref: '#/components/schemas/InventoryItem1' + InventoryItem2: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem2Status' + InventoryItem2Status: + type: string + enum: + - active + - inactive + InventoryItem2List: + type: array + items: + $ref: '#/components/schemas/InventoryItem2' + InventoryItem3: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem3Status' + InventoryItem3Status: + type: string + enum: + - active + - inactive + InventoryItem3List: + type: array + items: + $ref: '#/components/schemas/InventoryItem3' + InventoryItem4: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem4Status' + InventoryItem4Status: + type: string + enum: + - active + - inactive + InventoryItem4List: + type: array + items: + $ref: '#/components/schemas/InventoryItem4' + InventoryItem5: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem5Status' + InventoryItem5Status: + type: string + enum: + - active + - inactive + InventoryItem5List: + type: array + items: + $ref: '#/components/schemas/InventoryItem5' + InventoryItem6: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem6Status' + InventoryItem6Status: + type: string + enum: + - active + - inactive + InventoryItem6List: + type: array + items: + $ref: '#/components/schemas/InventoryItem6' + InventoryItem7: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem7Status' + InventoryItem7Status: + type: string + enum: + - active + - inactive + InventoryItem7List: + type: array + items: + $ref: '#/components/schemas/InventoryItem7' + InventoryItem8: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem8Status' + InventoryItem8Status: + type: string + enum: + - active + - inactive + InventoryItem8List: + type: array + items: + $ref: '#/components/schemas/InventoryItem8' + InventoryItem9: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem9Status' + InventoryItem9Status: + type: string + enum: + - active + - inactive + InventoryItem9List: + type: array + items: + $ref: '#/components/schemas/InventoryItem9' + InventoryItem10: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/InventoryItem10Status' + InventoryItem10Status: + type: string + enum: + - active + - inactive + InventoryItem10List: + type: array + items: + $ref: '#/components/schemas/InventoryItem10' + OrdersItem1: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem1Status' + OrdersItem1Status: + type: string + enum: + - active + - inactive + OrdersItem1List: + type: array + items: + $ref: '#/components/schemas/OrdersItem1' + OrdersItem2: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem2Status' + OrdersItem2Status: + type: string + enum: + - active + - inactive + OrdersItem2List: + type: array + items: + $ref: '#/components/schemas/OrdersItem2' + OrdersItem3: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem3Status' + OrdersItem3Status: + type: string + enum: + - active + - inactive + OrdersItem3List: + type: array + items: + $ref: '#/components/schemas/OrdersItem3' + OrdersItem4: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem4Status' + OrdersItem4Status: + type: string + enum: + - active + - inactive + OrdersItem4List: + type: array + items: + $ref: '#/components/schemas/OrdersItem4' + OrdersItem5: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem5Status' + OrdersItem5Status: + type: string + enum: + - active + - inactive + OrdersItem5List: + type: array + items: + $ref: '#/components/schemas/OrdersItem5' + OrdersItem6: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem6Status' + OrdersItem6Status: + type: string + enum: + - active + - inactive + OrdersItem6List: + type: array + items: + $ref: '#/components/schemas/OrdersItem6' + OrdersItem7: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem7Status' + OrdersItem7Status: + type: string + enum: + - active + - inactive + OrdersItem7List: + type: array + items: + $ref: '#/components/schemas/OrdersItem7' + OrdersItem8: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem8Status' + OrdersItem8Status: + type: string + enum: + - active + - inactive + OrdersItem8List: + type: array + items: + $ref: '#/components/schemas/OrdersItem8' + OrdersItem9: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem9Status' + OrdersItem9Status: + type: string + enum: + - active + - inactive + OrdersItem9List: + type: array + items: + $ref: '#/components/schemas/OrdersItem9' + OrdersItem10: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/OrdersItem10Status' + OrdersItem10Status: + type: string + enum: + - active + - inactive + OrdersItem10List: + type: array + items: + $ref: '#/components/schemas/OrdersItem10' + CustomersItem1: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem1Status' + CustomersItem1Status: + type: string + enum: + - active + - inactive + CustomersItem1List: + type: array + items: + $ref: '#/components/schemas/CustomersItem1' + CustomersItem2: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem2Status' + CustomersItem2Status: + type: string + enum: + - active + - inactive + CustomersItem2List: + type: array + items: + $ref: '#/components/schemas/CustomersItem2' + CustomersItem3: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem3Status' + CustomersItem3Status: + type: string + enum: + - active + - inactive + CustomersItem3List: + type: array + items: + $ref: '#/components/schemas/CustomersItem3' + CustomersItem4: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem4Status' + CustomersItem4Status: + type: string + enum: + - active + - inactive + CustomersItem4List: + type: array + items: + $ref: '#/components/schemas/CustomersItem4' + CustomersItem5: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem5Status' + CustomersItem5Status: + type: string + enum: + - active + - inactive + CustomersItem5List: + type: array + items: + $ref: '#/components/schemas/CustomersItem5' + CustomersItem6: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem6Status' + CustomersItem6Status: + type: string + enum: + - active + - inactive + CustomersItem6List: + type: array + items: + $ref: '#/components/schemas/CustomersItem6' + CustomersItem7: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem7Status' + CustomersItem7Status: + type: string + enum: + - active + - inactive + CustomersItem7List: + type: array + items: + $ref: '#/components/schemas/CustomersItem7' + CustomersItem8: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem8Status' + CustomersItem8Status: + type: string + enum: + - active + - inactive + CustomersItem8List: + type: array + items: + $ref: '#/components/schemas/CustomersItem8' + CustomersItem9: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem9Status' + CustomersItem9Status: + type: string + enum: + - active + - inactive + CustomersItem9List: + type: array + items: + $ref: '#/components/schemas/CustomersItem9' + CustomersItem10: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/CustomersItem10Status' + CustomersItem10Status: + type: string + enum: + - active + - inactive + CustomersItem10List: + type: array + items: + $ref: '#/components/schemas/CustomersItem10' + ShippingItem1: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem1Status' + ShippingItem1Status: + type: string + enum: + - active + - inactive + ShippingItem1List: + type: array + items: + $ref: '#/components/schemas/ShippingItem1' + ShippingItem2: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem2Status' + ShippingItem2Status: + type: string + enum: + - active + - inactive + ShippingItem2List: + type: array + items: + $ref: '#/components/schemas/ShippingItem2' + ShippingItem3: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem3Status' + ShippingItem3Status: + type: string + enum: + - active + - inactive + ShippingItem3List: + type: array + items: + $ref: '#/components/schemas/ShippingItem3' + ShippingItem4: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem4Status' + ShippingItem4Status: + type: string + enum: + - active + - inactive + ShippingItem4List: + type: array + items: + $ref: '#/components/schemas/ShippingItem4' + ShippingItem5: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem5Status' + ShippingItem5Status: + type: string + enum: + - active + - inactive + ShippingItem5List: + type: array + items: + $ref: '#/components/schemas/ShippingItem5' + ShippingItem6: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem6Status' + ShippingItem6Status: + type: string + enum: + - active + - inactive + ShippingItem6List: + type: array + items: + $ref: '#/components/schemas/ShippingItem6' + ShippingItem7: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem7Status' + ShippingItem7Status: + type: string + enum: + - active + - inactive + ShippingItem7List: + type: array + items: + $ref: '#/components/schemas/ShippingItem7' + ShippingItem8: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem8Status' + ShippingItem8Status: + type: string + enum: + - active + - inactive + ShippingItem8List: + type: array + items: + $ref: '#/components/schemas/ShippingItem8' + ShippingItem9: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem9Status' + ShippingItem9Status: + type: string + enum: + - active + - inactive + ShippingItem9List: + type: array + items: + $ref: '#/components/schemas/ShippingItem9' + ShippingItem10: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + status: + $ref: '#/components/schemas/ShippingItem10Status' + ShippingItem10Status: + type: string + enum: + - active + - inactive + ShippingItem10List: + type: array + items: + $ref: '#/components/schemas/ShippingItem10' diff --git a/test/fixtures/body-content-type-xml.openapi.yaml b/test/fixtures/body-content-type-xml.openapi.yaml new file mode 100644 index 0000000..c3e2777 --- /dev/null +++ b/test/fixtures/body-content-type-xml.openapi.yaml @@ -0,0 +1,17 @@ +openapi: 3.0.3 +info: { title: Xml Body, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + application/xml: + schema: { type: object, properties: { x: { type: string } } } + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-multi-content.openapi.yaml b/test/fixtures/body-multi-content.openapi.yaml new file mode 100644 index 0000000..38cfd18 --- /dev/null +++ b/test/fixtures/body-multi-content.openapi.yaml @@ -0,0 +1,19 @@ +openapi: 3.0.3 +info: { title: Multi Content Body, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + application/json: + schema: { type: object, properties: { x: { type: string } } } + multipart/form-data: + schema: { type: object, properties: { x: { type: string } } } + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-multipart-composed-field.openapi.yaml b/test/fixtures/body-multipart-composed-field.openapi.yaml new file mode 100644 index 0000000..f980cd4 --- /dev/null +++ b/test/fixtures/body-multipart-composed-field.openapi.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.3 +info: { title: Multipart Composed, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + variant: + oneOf: + - { type: string } + - { type: number } + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-multipart-mixed-fields.openapi.yaml b/test/fixtures/body-multipart-mixed-fields.openapi.yaml new file mode 100644 index 0000000..93111bc --- /dev/null +++ b/test/fixtures/body-multipart-mixed-fields.openapi.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.3 +info: { title: Multipart Mixed, version: 1.0.0 } +paths: + /pets/{petId}/avatar: + post: + operationId: updatePetAvatar + tags: [pet] + parameters: + - { name: petId, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [status, avatar, galleries] + properties: + status: { type: string } + tagIds: { type: array, items: { type: number } } + avatar: { type: string, format: binary } + galleries: { type: array, items: { type: string, format: binary } } + nickname: { type: string } + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + id: { type: string } diff --git a/test/fixtures/body-multipart-nested-object.openapi.yaml b/test/fixtures/body-multipart-nested-object.openapi.yaml new file mode 100644 index 0000000..bfee325 --- /dev/null +++ b/test/fixtures/body-multipart-nested-object.openapi.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.3 +info: { title: Multipart Nested, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + metadata: + type: object + properties: + authorId: { type: string } + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-multipart-non-object.openapi.yaml b/test/fixtures/body-multipart-non-object.openapi.yaml new file mode 100644 index 0000000..4c825a9 --- /dev/null +++ b/test/fixtures/body-multipart-non-object.openapi.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.3 +info: { title: Multipart Non Object, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: string + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-multipart-open-schema.openapi.yaml b/test/fixtures/body-multipart-open-schema.openapi.yaml new file mode 100644 index 0000000..1327d30 --- /dev/null +++ b/test/fixtures/body-multipart-open-schema.openapi.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.3 +info: { title: Multipart Open Schema, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + additionalProperties: true + properties: + status: { type: string } + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-multipart-ref-to-named-object.openapi.yaml b/test/fixtures/body-multipart-ref-to-named-object.openapi.yaml new file mode 100644 index 0000000..ee50976 --- /dev/null +++ b/test/fixtures/body-multipart-ref-to-named-object.openapi.yaml @@ -0,0 +1,30 @@ +openapi: 3.0.3 +info: { title: Multipart Ref, version: 1.0.0 } +paths: + /uploads: + post: + operationId: createUpload + tags: [upload] + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/UploadForm' + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + id: { type: string } +components: + schemas: + UploadForm: + type: object + required: [title] + properties: + title: { type: string } + description: { type: string } diff --git a/test/fixtures/body-urlencoded-binary-field.openapi.yaml b/test/fixtures/body-urlencoded-binary-field.openapi.yaml new file mode 100644 index 0000000..3c54bdc --- /dev/null +++ b/test/fixtures/body-urlencoded-binary-field.openapi.yaml @@ -0,0 +1,20 @@ +openapi: 3.0.3 +info: { title: Urlencoded Binary, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + avatar: { type: string, format: binary } + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-urlencoded-nested-object.openapi.yaml b/test/fixtures/body-urlencoded-nested-object.openapi.yaml new file mode 100644 index 0000000..93b2557 --- /dev/null +++ b/test/fixtures/body-urlencoded-nested-object.openapi.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.3 +info: { title: Urlencoded Nested, version: 1.0.0 } +paths: + /x: + post: + operationId: postX + tags: [x] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + metadata: + type: object + properties: + authorId: { type: string } + responses: + '200': + description: ok + content: + application/json: { schema: { type: object } } diff --git a/test/fixtures/body-urlencoded-scalar-and-array.openapi.yaml b/test/fixtures/body-urlencoded-scalar-and-array.openapi.yaml new file mode 100644 index 0000000..745db9f --- /dev/null +++ b/test/fixtures/body-urlencoded-scalar-and-array.openapi.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: { title: Urlencoded, version: 1.0.0 } +paths: + /search: + post: + operationId: submitSearch + tags: [search] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [query] + properties: + query: { type: string } + tagIds: { type: array, items: { type: number } } + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + count: { type: number } diff --git a/test/fixtures/circular-allof.openapi.yaml b/test/fixtures/circular-allof.openapi.yaml new file mode 100644 index 0000000..2b85758 --- /dev/null +++ b/test/fixtures/circular-allof.openapi.yaml @@ -0,0 +1,57 @@ +openapi: 3.0.3 +info: + title: Circular AllOf (Bottoms Out) + version: 1.0.0 +paths: {} +components: + schemas: + # Five layers of allOf composition that bottom out into a concrete + # object. This exercises the depth-tracking machinery in + # normalize/schema.rs (E4) on its happy path — well under + # MAX_NORMALIZE_DEPTH but deep enough that any off-by-one in the + # guard would surface as a fixture-level regression. + BaseAuditFields: + type: object + required: + - createdAt + properties: + createdAt: + type: string + updatedAt: + type: string + nullable: true + Layer1: + allOf: + - $ref: '#/components/schemas/BaseAuditFields' + - type: object + properties: + tier1: + type: string + Layer2: + allOf: + - $ref: '#/components/schemas/Layer1' + - type: object + properties: + tier2: + type: string + Layer3: + allOf: + - $ref: '#/components/schemas/Layer2' + - type: object + properties: + tier3: + type: string + Layer4: + allOf: + - $ref: '#/components/schemas/Layer3' + - type: object + properties: + tier4: + type: string + DeepRecord: + allOf: + - $ref: '#/components/schemas/Layer4' + - type: object + properties: + tier5: + type: string diff --git a/test/fixtures/consumer-forms-and-non-json.openapi.yaml b/test/fixtures/consumer-forms-and-non-json.openapi.yaml new file mode 100644 index 0000000..adfd40b --- /dev/null +++ b/test/fixtures/consumer-forms-and-non-json.openapi.yaml @@ -0,0 +1,86 @@ +openapi: 3.0.3 +info: + title: Consumer Forms and Non-JSON Responses + version: 1.0.0 +paths: + /pets/{petId}/avatar: + post: + operationId: updatePetAvatar + tags: [pet] + parameters: + - { name: petId, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [status, avatar, galleries, tagIds] + properties: + status: { type: string } + tagIds: { type: array, items: { type: integer } } + avatar: { type: string, format: binary } + galleries: { type: array, items: { type: string, format: binary } } + nickname: { type: string } + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + id: { type: string } + /search: + post: + operationId: submitForm + tags: [search] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [status, tagIds] + properties: + status: { type: string } + tagIds: { type: array, items: { type: integer } } + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + count: { type: number } + /invoices/{invoiceId}/pdf: + get: + operationId: downloadInvoicePdf + tags: [invoice] + parameters: + - { name: invoiceId, in: path, required: true, schema: { type: string } } + responses: + '200': + description: pdf + content: + application/pdf: {} + /config/raw: + get: + operationId: getRawConfig + tags: [config] + responses: + '200': + description: text + content: + text/plain: {} + /binary/fetch: + get: + operationId: fetchBlob + tags: [binary] + responses: + '200': + description: bytes + content: + application/octet-stream: + schema: { type: string, format: binary } diff --git a/test/fixtures/cookie-param.openapi.yaml b/test/fixtures/cookie-param.openapi.yaml new file mode 100644 index 0000000..d221e73 --- /dev/null +++ b/test/fixtures/cookie-param.openapi.yaml @@ -0,0 +1,19 @@ +openapi: 3.0.3 +info: + title: Cookie Param + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + parameters: + - name: sessionId + in: cookie + required: true + schema: + type: string + responses: + '200': + description: ok diff --git a/test/fixtures/deep-nested-allof.openapi.yaml b/test/fixtures/deep-nested-allof.openapi.yaml new file mode 100644 index 0000000..5231d2d --- /dev/null +++ b/test/fixtures/deep-nested-allof.openapi.yaml @@ -0,0 +1,222 @@ +# Pathological inline-allOf nesting (40 levels) — trips MAX_NORMALIZE_DEPTH=32. +# Each level adds an extra allOf wrapper around its child, so normalize +# recursion through normalize_composition_entries/normalize_schema bottoms +# out only after walking the full chain. Used to assert the depth guard +# in src/ir/normalize/schema.rs catches pathological / cyclic specs +# instead of overflowing the stack. +openapi: 3.0.3 +info: { title: Deep AllOf, version: '1.0' } +paths: {} +components: + schemas: + Deep: + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + allOf: + [ + { + type: object, + properties: + { + x: + { + type: string, + }, + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + } diff --git a/test/fixtures/deprecated-fields.openapi.yaml b/test/fixtures/deprecated-fields.openapi.yaml new file mode 100644 index 0000000..92b9c18 --- /dev/null +++ b/test/fixtures/deprecated-fields.openapi.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.3 +info: + title: Deprecated Fields + version: 1.0.0 +paths: + /pets/{petId}: + get: + operationId: getPet + summary: Retrieve a single pet + description: Use `getPetById` instead — this endpoint will be removed. + deprecated: true + tags: + - Pet + parameters: + - name: petId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: string + legacyTagId: + type: number + description: Legacy numeric tag id; use `tagIds` instead. + deprecated: true + tagIds: + type: array + items: + type: number + LegacyPetStatus: + type: string + description: Adoption state, legacy spelling. + deprecated: true + enum: + - available + - sold diff --git a/test/fixtures/discriminated-union.openapi.yaml b/test/fixtures/discriminated-union.openapi.yaml new file mode 100644 index 0000000..b1f9ba2 --- /dev/null +++ b/test/fixtures/discriminated-union.openapi.yaml @@ -0,0 +1,33 @@ +openapi: 3.0.3 +info: + title: Discriminated Union + version: 1.0.0 +paths: {} +components: + schemas: + Cat: + type: object + required: + - kind + - lives + properties: + kind: + type: string + lives: + type: integer + Dog: + type: object + required: + - kind + - breed + properties: + kind: + type: string + breed: + type: string + PetUnion: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: kind diff --git a/test/fixtures/discriminator-allof.openapi.yaml b/test/fixtures/discriminator-allof.openapi.yaml new file mode 100644 index 0000000..19e460f --- /dev/null +++ b/test/fixtures/discriminator-allof.openapi.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: { title: discriminator-allof, version: '1.0.0' } +paths: {} +components: + schemas: + # Base type carrying the discriminator property. + Animal: + type: object + required: [name] + properties: + name: { type: string } + # Pet is the oneOf root with a discriminator on `kind`. + # Each member uses `allOf` to compose a shared base (Animal) with a + # variant-specific tail that redeclares the discriminator property — + # the canonical OpenAPI shape for typed unions with a shared + # ancestor. The normalize pass must narrow `kind` on the inline + # part of each Intersection. + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: kind + Cat: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + required: [kind] + properties: + kind: { type: string } + whiskers: { type: number } + Dog: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + required: [kind] + properties: + kind: { type: string } + barkLoudness: { type: string } diff --git a/test/fixtures/discriminator-mapping-external-ref.openapi.yaml b/test/fixtures/discriminator-mapping-external-ref.openapi.yaml new file mode 100644 index 0000000..5d501b1 --- /dev/null +++ b/test/fixtures/discriminator-mapping-external-ref.openapi.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: { title: discriminator-mapping-external-ref, version: '1.0.0' } +paths: {} +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: kind + mapping: + feline: 'http://example.com/schemas/Cat' + canine: '#/components/schemas/Dog' + Cat: + type: object + required: [kind] + properties: + kind: { type: string } + Dog: + type: object + required: [kind] + properties: + kind: { type: string } diff --git a/test/fixtures/discriminator-mapping.openapi.yaml b/test/fixtures/discriminator-mapping.openapi.yaml new file mode 100644 index 0000000..d2d37c9 --- /dev/null +++ b/test/fixtures/discriminator-mapping.openapi.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: { title: discriminator-mapping, version: '1.0.0' } +paths: {} +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: kind + mapping: + feline: '#/components/schemas/Cat' + canine: '#/components/schemas/Dog' + Cat: + type: object + required: [kind] + properties: + kind: { type: string } + Dog: + type: object + required: [kind] + properties: + kind: { type: string } diff --git a/test/fixtures/discriminator-missing-property.openapi.yaml b/test/fixtures/discriminator-missing-property.openapi.yaml new file mode 100644 index 0000000..2fe777f --- /dev/null +++ b/test/fixtures/discriminator-missing-property.openapi.yaml @@ -0,0 +1,20 @@ +openapi: 3.0.3 +info: { title: D, version: '1.0' } +paths: {} +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: { propertyName: kind } + Cat: + type: object + properties: + name: { type: string } + # intentionally missing: kind + Dog: + type: object + properties: + kind: { type: string } + bark: { type: boolean } diff --git a/test/fixtures/duplicate-operation-id.openapi.yaml b/test/fixtures/duplicate-operation-id.openapi.yaml new file mode 100644 index 0000000..b7fc146 --- /dev/null +++ b/test/fixtures/duplicate-operation-id.openapi.yaml @@ -0,0 +1,17 @@ +openapi: 3.0.3 +info: + title: Dup OpId + version: 1.0.0 +paths: + /a: + get: + operationId: doIt + responses: + '200': + description: ok + /b: + get: + operationId: doIt + responses: + '200': + description: ok diff --git a/test/fixtures/duplicate-schema-name.openapi.yaml b/test/fixtures/duplicate-schema-name.openapi.yaml new file mode 100644 index 0000000..a363cf8 --- /dev/null +++ b/test/fixtures/duplicate-schema-name.openapi.yaml @@ -0,0 +1,13 @@ +openapi: 3.0.3 +info: + title: Dup Schema + version: 1.0.0 +paths: {} +components: + schemas: + Pet: + type: object + properties: { id: { type: string } } + Pet: + type: object + properties: { name: { type: string } } diff --git a/test/fixtures/empty-parameter.openapi.yaml b/test/fixtures/empty-parameter.openapi.yaml new file mode 100644 index 0000000..5c4972c --- /dev/null +++ b/test/fixtures/empty-parameter.openapi.yaml @@ -0,0 +1,32 @@ +openapi: 3.0.3 +info: + title: Empty Parameter Schema + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + parameters: + - name: filter + in: query + schema: {} + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/PetList' +components: + schemas: + PetList: + type: object + required: + - items + properties: + items: + type: array + items: + type: string diff --git a/test/fixtures/empty-shapes.openapi.yaml b/test/fixtures/empty-shapes.openapi.yaml new file mode 100644 index 0000000..d0cf1f3 --- /dev/null +++ b/test/fixtures/empty-shapes.openapi.yaml @@ -0,0 +1,33 @@ +openapi: 3.0.3 +info: + title: Empty Schema Shapes + version: 1.0.0 +paths: {} +components: + schemas: + AnyValue: {} + EmptyObject: + type: object + EmptyObjectWithProperties: + type: object + properties: {} + ShapeContainer: + type: object + required: + - anything + - emptyInline + - emptyArray + - emptyMap + properties: + anything: {} + emptyInline: + type: object + emptyInlineWithProperties: + type: object + properties: {} + emptyArray: + type: array + items: {} + emptyMap: + type: object + additionalProperties: {} diff --git a/test/fixtures/errors-typed.openapi.yaml b/test/fixtures/errors-typed.openapi.yaml new file mode 100644 index 0000000..91982ee --- /dev/null +++ b/test/fixtures/errors-typed.openapi.yaml @@ -0,0 +1,95 @@ +openapi: 3.0.3 +info: + title: Errors Typed + version: 1.0.0 +paths: + /pets/{petId}: + post: + operationId: updatePet + tags: + - Pet + parameters: + - name: petId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePetRequest' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: validation failure + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationProblem' + '404': + description: not found + content: + application/json: + schema: + $ref: '#/components/schemas/NotFound' + '500': + description: server error + content: + application/json: + schema: + type: object + required: + - traceId + properties: + traceId: + type: string + # Non-2xx without JSON content — must be silently skipped. + '503': + description: maintenance + # `default` key is intentionally ignored for now. + default: + description: catch-all + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationProblem' +components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: string + UpdatePetRequest: + type: object + required: + - status + properties: + status: + type: string + ValidationProblem: + type: object + required: + - code + - field + properties: + code: + type: string + field: + type: string + NotFound: + type: object + required: + - resource + properties: + resource: + type: string diff --git a/test/fixtures/external-ref.openapi.yaml b/test/fixtures/external-ref.openapi.yaml new file mode 100644 index 0000000..73d5ba5 --- /dev/null +++ b/test/fixtures/external-ref.openapi.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.3 +info: + title: External Ref + version: 1.0.0 +paths: {} +components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: string + owner: + $ref: 'shared.yaml#/components/schemas/Owner' diff --git a/test/fixtures/field-collision.openapi.yaml b/test/fixtures/field-collision.openapi.yaml new file mode 100644 index 0000000..1dbb4d6 --- /dev/null +++ b/test/fixtures/field-collision.openapi.yaml @@ -0,0 +1,32 @@ +openapi: 3.0.3 +info: + title: Field Collision + version: 1.0.0 +paths: + /users/{id}: + post: + operationId: createUser + tags: + - User + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: string + required: + - id + responses: + '201': + description: created +components: + schemas: {} diff --git a/test/fixtures/header-param.openapi.yaml b/test/fixtures/header-param.openapi.yaml new file mode 100644 index 0000000..661b594 --- /dev/null +++ b/test/fixtures/header-param.openapi.yaml @@ -0,0 +1,19 @@ +openapi: 3.0.3 +info: + title: Header Param + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + parameters: + - name: X-Api-Key + in: header + required: true + schema: + type: string + responses: + '200': + description: ok diff --git a/test/fixtures/inline-model.openapi.yaml b/test/fixtures/inline-model.openapi.yaml new file mode 100644 index 0000000..02ff621 --- /dev/null +++ b/test/fixtures/inline-model.openapi.yaml @@ -0,0 +1,56 @@ +openapi: 3.0.3 +info: + title: Inline Model Schemas + version: 1.0.0 +paths: {} +components: + schemas: + PetProfile: + type: object + required: + - id + - details + - labelsByLocale + - visits + properties: + id: + type: string + details: + type: object + required: + - displayName + - address + properties: + displayName: + type: string + address: + type: object + required: + - city + properties: + city: + type: string + postalCode: + type: string + nullable: true + labelsByLocale: + type: object + additionalProperties: + type: object + required: + - value + properties: + value: + type: string + visits: + type: array + items: + type: object + required: + - visitedAt + properties: + visitedAt: + type: string + notes: + type: string + nullable: true diff --git a/test/fixtures/inline-parameter.openapi.yaml b/test/fixtures/inline-parameter.openapi.yaml new file mode 100644 index 0000000..e9b7c3f --- /dev/null +++ b/test/fixtures/inline-parameter.openapi.yaml @@ -0,0 +1,36 @@ +openapi: 3.0.3 +info: + title: Inline Parameter Schema + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + parameters: + - name: filter + in: query + schema: + type: object + properties: + breed: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/PetList' +components: + schemas: + PetList: + type: object + required: + - items + properties: + items: + type: array + items: + type: string diff --git a/test/fixtures/invalid-enum-type.openapi.yaml b/test/fixtures/invalid-enum-type.openapi.yaml new file mode 100644 index 0000000..15612f1 --- /dev/null +++ b/test/fixtures/invalid-enum-type.openapi.yaml @@ -0,0 +1,11 @@ +openapi: 3.0.3 +info: + title: Enum Type Guard + version: 1.0.0 +paths: {} +components: + schemas: + InvalidEnum: + type: integer + enum: + - A diff --git a/test/fixtures/invalid-enum-value.openapi.json b/test/fixtures/invalid-enum-value.openapi.json new file mode 100644 index 0000000..c4e2c7a --- /dev/null +++ b/test/fixtures/invalid-enum-value.openapi.json @@ -0,0 +1,16 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Enum Value Validation", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "InvalidEnum": { + "type": "string", + "enum": ["bad\u0000value"] + } + } + } +} diff --git a/test/fixtures/jsdoc-descriptions.openapi.yaml b/test/fixtures/jsdoc-descriptions.openapi.yaml new file mode 100644 index 0000000..d025c77 --- /dev/null +++ b/test/fixtures/jsdoc-descriptions.openapi.yaml @@ -0,0 +1,51 @@ +openapi: 3.0.3 +info: + title: JSDoc Descriptions + version: 1.0.0 +paths: + /pets/{petId}: + get: + operationId: getPet + summary: Retrieve a single pet + description: |- + Returns the full pet record by id. The returned shape + matches `Pet` exactly — no partial fetches. + tags: + - Pet + parameters: + - name: petId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + description: A pet that is up for adoption. + required: + - id + - status + properties: + id: + type: string + description: Stable identifier across renames. + status: + $ref: '#/components/schemas/PetStatus' + nickname: + type: string + description: Optional informal name used in marketing copy. + PetStatus: + type: string + description: Adoption state of the pet. + enum: + - available + - pending + - adopted diff --git a/test/fixtures/large-enum.openapi.yaml b/test/fixtures/large-enum.openapi.yaml new file mode 100644 index 0000000..ecd89ac --- /dev/null +++ b/test/fixtures/large-enum.openapi.yaml @@ -0,0 +1,72 @@ +openapi: 3.0.3 +info: + title: Large Enum + version: 1.0.0 +paths: {} +components: + schemas: + UsState: + type: string + enum: + - alabama + - alaska + - arizona + - arkansas + - california + - colorado + - connecticut + - delaware + - florida + - georgia + - hawaii + - idaho + - illinois + - indiana + - iowa + - kansas + - kentucky + - louisiana + - maine + - maryland + - massachusetts + - michigan + - minnesota + - mississippi + - missouri + - montana + - nebraska + - nevada + - new-hampshire + - new-jersey + - new-mexico + - new-york + - north-carolina + - north-dakota + - ohio + - oklahoma + - oregon + - pennsylvania + - rhode-island + - south-carolina + - south-dakota + - tennessee + - texas + - utah + - vermont + - virginia + - washington + - west-virginia + - wisconsin + - wyoming + AddressBook: + type: object + required: + - state + properties: + state: + $ref: '#/components/schemas/UsState' + compactStatus: + type: string + enum: + - active + - sealed diff --git a/test/fixtures/malformed.yaml b/test/fixtures/malformed.yaml new file mode 100644 index 0000000..5a0375c --- /dev/null +++ b/test/fixtures/malformed.yaml @@ -0,0 +1,11 @@ +openapi: 3.0.3 +info: + title: Malformed + version: 1.0.0 +paths: + /pets: + get: + responses: + '200': + description: ok + broken: [ diff --git a/test/fixtures/missing-tag.openapi.yaml b/test/fixtures/missing-tag.openapi.yaml new file mode 100644 index 0000000..ad0d683 --- /dev/null +++ b/test/fixtures/missing-tag.openapi.yaml @@ -0,0 +1,13 @@ +openapi: 3.0.3 +info: + title: Missing Tag + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + responses: + '200': + description: ok +components: + schemas: {} diff --git a/test/fixtures/multi-tag-operation.openapi.yaml b/test/fixtures/multi-tag-operation.openapi.yaml new file mode 100644 index 0000000..cbc0346 --- /dev/null +++ b/test/fixtures/multi-tag-operation.openapi.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.3 +info: + title: Multi Tag Operation + version: 1.0.0 +paths: + /pets: + get: + operationId: listPetsAndShelters + tags: + - Pet + - Shelter + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/PetList' +components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: string + PetList: + type: array + items: + $ref: '#/components/schemas/Pet' diff --git a/test/fixtures/multi-warning.openapi.yaml b/test/fixtures/multi-warning.openapi.yaml new file mode 100644 index 0000000..7c68476 --- /dev/null +++ b/test/fixtures/multi-warning.openapi.yaml @@ -0,0 +1,41 @@ +openapi: 3.0.3 +info: + title: Multi Warning + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + # Two cookie parameters on one operation produce two normalize-stage + # warnings (each cookie is dropped from the generated contract). + # Pins the warning order — first sessionId, then trackingId — so a + # regression that reorders or coalesces stage warnings shows up as a + # snapshot diff rather than silently passing under deep-equality. + parameters: + - name: sessionId + in: cookie + required: true + schema: + type: string + - name: trackingId + in: cookie + required: false + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + required: [id] + properties: + id: + type: string diff --git a/test/fixtures/nullable-oneof.openapi.yaml b/test/fixtures/nullable-oneof.openapi.yaml new file mode 100644 index 0000000..3b9d5fc --- /dev/null +++ b/test/fixtures/nullable-oneof.openapi.yaml @@ -0,0 +1,46 @@ +openapi: 3.0.3 +info: + title: Nullable OneOf + version: 1.0.0 +paths: {} +components: + schemas: + Cat: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - cat + whiskers: + type: integer + Dog: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - dog + breed: + type: string + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + nullable: true + Profile: + type: object + required: + - id + properties: + id: + type: string + favoritePet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + nullable: true diff --git a/test/fixtures/nullable-optional.openapi.yaml b/test/fixtures/nullable-optional.openapi.yaml new file mode 100644 index 0000000..0433a29 --- /dev/null +++ b/test/fixtures/nullable-optional.openapi.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.3 +info: + title: Nullable optional fixture + version: 1.0.0 +paths: {} +components: + schemas: + Profile: + type: object + required: + - id + - displayName + properties: + id: + type: string + displayName: + type: string + nickname: + type: string + nullable: true + bio: + type: string + legalName: + type: string + nullable: true diff --git a/test/fixtures/oneof-anyof-composition.openapi.json b/test/fixtures/oneof-anyof-composition.openapi.json new file mode 100644 index 0000000..8b56513 --- /dev/null +++ b/test/fixtures/oneof-anyof-composition.openapi.json @@ -0,0 +1,163 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "OneOf AnyOf Composition", + "version": "1.0.0" + }, + "paths": { + "/union-pets": { + "get": { + "operationId": "listUnionPets", + "tags": ["Pet"], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PetUnionList" + } + } + } + } + } + } + }, + "/adoption-request": { + "post": { + "operationId": "createAdoptionRequest", + "tags": ["AdoptionRequest"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdoptionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdoptionDecision" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "required": ["id", "lives"], + "properties": { + "id": { + "type": "string" + }, + "lives": { + "type": "integer" + } + } + }, + "Dog": { + "type": "object", + "required": ["id", "breed"], + "properties": { + "id": { + "type": "string" + }, + "breed": { + "type": "string" + } + } + }, + "PetUnion": { + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + }, + "PetUnionList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PetUnion" + } + }, + "ContactEmail": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + }, + "ContactPhone": { + "type": "object", + "required": ["phone"], + "properties": { + "phone": { + "type": "string" + } + } + }, + "AdoptionRequest": { + "type": "object", + "required": ["applicantName", "contact"], + "properties": { + "applicantName": { + "type": "string" + }, + "preferredPet": { + "$ref": "#/components/schemas/PetUnion" + }, + "contact": { + "anyOf": [ + { + "$ref": "#/components/schemas/ContactEmail" + }, + { + "$ref": "#/components/schemas/ContactPhone" + } + ] + }, + "notes": { + "type": "string" + }, + "referralCode": { + "type": "string", + "nullable": true + } + } + }, + "AdoptionDecision": { + "type": "object", + "required": ["approved"], + "properties": { + "approved": { + "type": "boolean" + }, + "matchedPet": { + "$ref": "#/components/schemas/PetUnion" + }, + "reviewerNote": { + "type": "string", + "nullable": true + } + } + } + } + } +} diff --git a/test/fixtures/oneof-anyof-composition.openapi.yaml b/test/fixtures/oneof-anyof-composition.openapi.yaml new file mode 100644 index 0000000..19aa851 --- /dev/null +++ b/test/fixtures/oneof-anyof-composition.openapi.yaml @@ -0,0 +1,111 @@ +openapi: 3.0.3 +info: + title: OneOf AnyOf Composition + version: 1.0.0 +paths: + /union-pets: + get: + operationId: listUnionPets + tags: + - Pet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/PetUnionList' + /adoption-request: + post: + operationId: createAdoptionRequest + tags: + - AdoptionRequest + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AdoptionRequest' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/AdoptionDecision' +components: + schemas: + Cat: + type: object + required: + - id + - lives + properties: + id: + type: string + lives: + type: integer + Dog: + type: object + required: + - id + - breed + properties: + id: + type: string + breed: + type: string + PetUnion: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + PetUnionList: + type: array + items: + $ref: '#/components/schemas/PetUnion' + ContactEmail: + type: object + required: + - email + properties: + email: + type: string + format: email + ContactPhone: + type: object + required: + - phone + properties: + phone: + type: string + AdoptionRequest: + type: object + required: + - applicantName + - contact + properties: + applicantName: + type: string + preferredPet: + $ref: '#/components/schemas/PetUnion' + contact: + anyOf: + - $ref: '#/components/schemas/ContactEmail' + - $ref: '#/components/schemas/ContactPhone' + notes: + type: string + referralCode: + type: string + nullable: true + AdoptionDecision: + type: object + required: + - approved + properties: + approved: + type: boolean + matchedPet: + $ref: '#/components/schemas/PetUnion' + reviewerNote: + type: string + nullable: true diff --git a/test/fixtures/petstore-minimal.openapi.json b/test/fixtures/petstore-minimal.openapi.json new file mode 100644 index 0000000..b822b74 --- /dev/null +++ b/test/fixtures/petstore-minimal.openapi.json @@ -0,0 +1,27 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Petstore Minimal", + "version": "1.0.0" + }, + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "tags": ["Pet"], + "responses": { + "200": { + "description": "ok" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object" + } + } + } +} diff --git a/test/fixtures/petstore-minimal.openapi.yaml b/test/fixtures/petstore-minimal.openapi.yaml new file mode 100644 index 0000000..47bd9d0 --- /dev/null +++ b/test/fixtures/petstore-minimal.openapi.yaml @@ -0,0 +1,17 @@ +openapi: 3.0.3 +info: + title: Petstore Minimal + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + responses: + '200': + description: ok +components: + schemas: + Pet: + type: object diff --git a/test/fixtures/petstore-rich.openapi.json b/test/fixtures/petstore-rich.openapi.json new file mode 100644 index 0000000..059bba9 --- /dev/null +++ b/test/fixtures/petstore-rich.openapi.json @@ -0,0 +1,172 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Petstore Rich", + "version": "1.0.0" + }, + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "tags": ["Pet"], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PetList" + } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "operationId": "getPet", + "tags": ["Pet"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/PetId" + } + } + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + }, + "post": { + "operationId": "updatePet", + "tags": ["Pet"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/PetId" + } + }, + { + "name": "includeHistory", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePetRequest" + } + } + } + }, + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PetId": { + "type": "string" + }, + "PetStatus": { + "type": "string", + "enum": ["available", "pending", "sold"] + }, + "Tag": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { + "type": "integer" + }, + "label": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "required": ["id", "name", "status", "tags"], + "properties": { + "id": { + "$ref": "#/components/schemas/PetId" + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/PetStatus" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "nickname": { + "type": "string", + "nullable": true + } + } + }, + "PetList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "UpdatePetRequest": { + "type": "object", + "required": ["status", "tagIds"], + "properties": { + "status": { + "$ref": "#/components/schemas/PetStatus" + }, + "tagIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "nickname": { + "type": "string", + "nullable": true + } + } + } + } + } +} diff --git a/test/fixtures/petstore-rich.openapi.yaml b/test/fixtures/petstore-rich.openapi.yaml new file mode 100644 index 0000000..58d2f52 --- /dev/null +++ b/test/fixtures/petstore-rich.openapi.yaml @@ -0,0 +1,123 @@ +openapi: 3.0.3 +info: + title: Petstore Rich + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/PetList' + /pets/{petId}: + get: + operationId: getPet + tags: + - Pet + parameters: + - name: petId + in: path + required: true + schema: + $ref: '#/components/schemas/PetId' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + post: + operationId: updatePet + tags: + - Pet + parameters: + - name: petId + in: path + required: true + schema: + $ref: '#/components/schemas/PetId' + - name: includeHistory + in: query + required: false + schema: + type: boolean + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePetRequest' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + PetId: + type: string + PetStatus: + type: string + enum: + - available + - pending + - sold + Tag: + type: object + required: + - id + - label + properties: + id: + type: integer + label: + type: string + Pet: + type: object + required: + - id + - name + - status + - tags + properties: + id: + $ref: '#/components/schemas/PetId' + name: + type: string + status: + $ref: '#/components/schemas/PetStatus' + tags: + type: array + items: + $ref: '#/components/schemas/Tag' + nickname: + type: string + nullable: true + PetList: + type: array + items: + $ref: '#/components/schemas/Pet' + UpdatePetRequest: + type: object + required: + - status + - tagIds + properties: + status: + $ref: '#/components/schemas/PetStatus' + tagIds: + type: array + items: + type: integer + nickname: + type: string + nullable: true diff --git a/test/fixtures/recursive-model.openapi.yaml b/test/fixtures/recursive-model.openapi.yaml new file mode 100644 index 0000000..ad5cb15 --- /dev/null +++ b/test/fixtures/recursive-model.openapi.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.3 +info: + title: Recursive Model + version: 1.0.0 +paths: {} +components: + schemas: + Category: + type: object + required: + - name + - subcategories + properties: + name: + type: string + subcategories: + type: array + items: + $ref: '#/components/schemas/Category' + Person: + type: object + required: + - name + properties: + name: + type: string + favoritePet: + $ref: '#/components/schemas/Pet' + Pet: + type: object + required: + - name + properties: + name: + type: string + owner: + $ref: '#/components/schemas/Person' diff --git a/test/fixtures/recursive-oneof.openapi.yaml b/test/fixtures/recursive-oneof.openapi.yaml new file mode 100644 index 0000000..50e08ba --- /dev/null +++ b/test/fixtures/recursive-oneof.openapi.yaml @@ -0,0 +1,31 @@ +# Self-referential cycle through `oneOf` composition. `recursive-model` +# pins cycles through plain `$ref` properties; `circular-allof` pins +# deep `allOf` chains. This fixture closes the gap for cycles routed +# through union composition: a TreeNode is either a leaf scalar object +# or a list of TreeNode children. Locks down that named cycles +# through oneOf members resolve to a regular union type alias (the IR +# carries the back-edge as an unresolved Ref), not a normalize-time +# diagnostic or an infinite-recursion fault. +openapi: 3.0.3 +info: + title: Recursive OneOf + version: 1.0.0 +paths: {} +components: + schemas: + TreeNode: + oneOf: + - type: object + required: + - leaf + properties: + leaf: + type: string + - type: object + required: + - children + properties: + children: + type: array + items: + $ref: '#/components/schemas/TreeNode' diff --git a/test/fixtures/reserved-prop-names.openapi.yaml b/test/fixtures/reserved-prop-names.openapi.yaml new file mode 100644 index 0000000..d68ae2c --- /dev/null +++ b/test/fixtures/reserved-prop-names.openapi.yaml @@ -0,0 +1,30 @@ +openapi: 3.0.3 +info: + title: Reserved And Non-Identifier Property Names + version: 1.0.0 +paths: {} +components: + schemas: + NonIdentifierProps: + type: object + required: + - class + - id + properties: + # Reserved word — valid as a property name in TS, no quoting needed. + class: + type: string + id: + type: string + # Digit-first — must be quoted. + 2legged: + type: string + # Kebab-case — must be quoted. + kebab-case: + type: boolean + # Dotted — must be quoted. + dotted.name: + type: integer + # Embedded space — must be quoted. + with space: + type: string diff --git a/test/fixtures/response-204-no-content.openapi.yaml b/test/fixtures/response-204-no-content.openapi.yaml new file mode 100644 index 0000000..18b2a73 --- /dev/null +++ b/test/fixtures/response-204-no-content.openapi.yaml @@ -0,0 +1,10 @@ +openapi: 3.0.3 +info: { title: Empty, version: 1.0.0 } +paths: + /ping: + delete: + operationId: deletePing + tags: [util] + responses: + '204': + description: deleted diff --git a/test/fixtures/response-blob-via-pdf.openapi.yaml b/test/fixtures/response-blob-via-pdf.openapi.yaml new file mode 100644 index 0000000..9ef8c8e --- /dev/null +++ b/test/fixtures/response-blob-via-pdf.openapi.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: { title: PDF Response, version: 1.0.0 } +paths: + /reports/{id}: + get: + operationId: getReport + tags: [report] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: pdf + content: + application/pdf: {} diff --git a/test/fixtures/response-default-fallback.openapi.yaml b/test/fixtures/response-default-fallback.openapi.yaml new file mode 100644 index 0000000..1c816ee --- /dev/null +++ b/test/fixtures/response-default-fallback.openapi.yaml @@ -0,0 +1,22 @@ +openapi: 3.0.3 +info: { title: Default, version: 1.0.0 } +paths: + /thing: + get: + operationId: getThing + tags: [thing] + responses: + '200': + description: ok + content: + application/json: + schema: { $ref: '#/components/schemas/Thing' } + 'default': + description: error + content: + application/json: + schema: { $ref: '#/components/schemas/Err' } +components: + schemas: + Thing: { type: object, properties: { id: { type: string } } } + Err: { type: object, properties: { message: { type: string } } } diff --git a/test/fixtures/response-octet-stream.openapi.yaml b/test/fixtures/response-octet-stream.openapi.yaml new file mode 100644 index 0000000..cef23b7 --- /dev/null +++ b/test/fixtures/response-octet-stream.openapi.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.3 +info: { title: Blob, version: 1.0.0 } +paths: + /blob/{id}: + get: + operationId: getBlob + tags: [blob] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: bytes + content: + application/octet-stream: + schema: { type: string, format: binary } diff --git a/test/fixtures/response-problem-json.openapi.yaml b/test/fixtures/response-problem-json.openapi.yaml new file mode 100644 index 0000000..2ca982c --- /dev/null +++ b/test/fixtures/response-problem-json.openapi.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.3 +info: { title: Problem JSON, version: 1.0.0 } +paths: + /errors/{id}: + get: + operationId: getError + tags: [error] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: problem + content: + application/problem+json: + schema: + type: object + properties: + detail: { type: string } diff --git a/test/fixtures/response-text-via-text-plain.openapi.yaml b/test/fixtures/response-text-via-text-plain.openapi.yaml new file mode 100644 index 0000000..391c955 --- /dev/null +++ b/test/fixtures/response-text-via-text-plain.openapi.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: { title: Text Response, version: 1.0.0 } +paths: + /notes/{id}: + get: + operationId: getNote + tags: [note] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: text + content: + text/plain: {} diff --git a/test/fixtures/security-schemes.openapi.yaml b/test/fixtures/security-schemes.openapi.yaml new file mode 100644 index 0000000..6780ad2 --- /dev/null +++ b/test/fixtures/security-schemes.openapi.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.3 +info: + title: Security Schemes + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + tags: + - Pet + security: + - apiKey: [] + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/PetList' +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Api-Key + bearerAuth: + type: http + scheme: bearer + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: string + PetList: + type: array + items: + $ref: '#/components/schemas/Pet' diff --git a/test/fixtures/single-entry-composition.openapi.yaml b/test/fixtures/single-entry-composition.openapi.yaml new file mode 100644 index 0000000..4ea0b8e --- /dev/null +++ b/test/fixtures/single-entry-composition.openapi.yaml @@ -0,0 +1,47 @@ +openapi: 3.0.3 +info: + title: Single Entry Composition + version: 1.0.0 +paths: + /animals/{animalId}: + get: + operationId: getAnimal + tags: + - Animal + parameters: + - name: animalId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/AnimalView' +components: + schemas: + AnimalBase: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + nickname: + type: string + nullable: true + AnimalView: + oneOf: + - $ref: '#/components/schemas/AnimalBase' + AnimalDraft: + allOf: + - $ref: '#/components/schemas/AnimalBase' + ContactPreference: + anyOf: + - type: string diff --git a/test/fixtures/string-formats.openapi.yaml b/test/fixtures/string-formats.openapi.yaml new file mode 100644 index 0000000..f7e6454 --- /dev/null +++ b/test/fixtures/string-formats.openapi.yaml @@ -0,0 +1,33 @@ +openapi: 3.0.3 +info: + title: String Formats + version: 1.0.0 +paths: {} +components: + schemas: + Contact: + type: object + required: + - email + - identifier + - homepage + - birthday + - lastSeen + properties: + email: + type: string + format: email + identifier: + type: string + format: uuid + homepage: + type: string + format: uri + birthday: + type: string + format: date + lastSeen: + type: string + format: date-time + notes: + type: string diff --git a/test/fixtures/unbalanced-path-template.openapi.yaml b/test/fixtures/unbalanced-path-template.openapi.yaml new file mode 100644 index 0000000..3961bef --- /dev/null +++ b/test/fixtures/unbalanced-path-template.openapi.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.3 +info: + title: Unbalanced Path Template + version: 1.0.0 +# The path string `/pets/{id` has an unmatched `{`. Without the +# normalize-stage check this would emit a broken template like +# `url: `/pets/id`` — silently dropping the `${encodeURIComponent(id)}` +# expansion. Normalize rejects the spec instead. +paths: + /pets/{id: + get: + operationId: getPet + tags: + - Pet + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: ok diff --git a/test/fixtures/unsupported-root.yaml b/test/fixtures/unsupported-root.yaml new file mode 100644 index 0000000..ab18858 --- /dev/null +++ b/test/fixtures/unsupported-root.yaml @@ -0,0 +1,8 @@ +openapi: 3.0.3 +info: + title: Unsupported Root + version: 1.0.0 +paths: {} +components: + schemas: + - bad diff --git a/test/fixtures/unsupported-semantic.openapi.yaml b/test/fixtures/unsupported-semantic.openapi.yaml new file mode 100644 index 0000000..d6ae99b --- /dev/null +++ b/test/fixtures/unsupported-semantic.openapi.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.3 +info: + title: Unsupported Semantic + version: 1.0.0 +paths: + /events: + get: + operationId: listEvents + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/EventEnvelope' +components: + schemas: + EventEnvelope: + allOf: + - $ref: '#/components/schemas/BaseEnvelope' + - type: object + required: + - data + properties: + data: + type: object + additionalProperties: true + BaseEnvelope: + type: object + required: + - kind + properties: + kind: + type: string diff --git a/test/fixtures/unsupported-trace.openapi.yaml b/test/fixtures/unsupported-trace.openapi.yaml new file mode 100644 index 0000000..e509af9 --- /dev/null +++ b/test/fixtures/unsupported-trace.openapi.yaml @@ -0,0 +1,13 @@ +openapi: 3.0.3 +info: + title: Unsupported TRACE Method + version: 1.0.0 +paths: + /pets: + trace: + operationId: tracePets + tags: + - Pet + responses: + '200': + description: ok diff --git a/test/fixtures/verb-prefix.openapi.yaml b/test/fixtures/verb-prefix.openapi.yaml new file mode 100644 index 0000000..7469641 --- /dev/null +++ b/test/fixtures/verb-prefix.openapi.yaml @@ -0,0 +1,30 @@ +openapi: 3.0.3 +info: + title: VerbPrefix + version: 1.0.0 +paths: + /posts: + get: + operationId: posts_listAll + tags: [Post] + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: string + post: + operationId: posts_create + tags: [Post] + requestBody: + required: true + content: + application/json: + schema: + type: string + responses: + '200': + description: OK diff --git a/test/fixtures/warning-then-fatal.openapi.yaml b/test/fixtures/warning-then-fatal.openapi.yaml new file mode 100644 index 0000000..9c003aa --- /dev/null +++ b/test/fixtures/warning-then-fatal.openapi.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: Warning Then Fatal + version: 1.0.0 +# Fixture for the NAPI error-enrichment integration test. The first +# operation emits a cookie-parameter warning during normalize; the second +# operation triggers a fatal via an unsupported parameter location. The +# expected end-state: generate throws GenerateError, the fatal +# carries the bogus-location message, and error.warnings carries the +# pre-fatal cookie warning. +paths: + /a: + get: + operationId: opWithCookieWarning + tags: + - Sample + parameters: + - name: sessionId + in: cookie + required: true + schema: + type: string + responses: + '200': + description: ok + /b: + get: + operationId: opWithFatal + tags: + - Sample + parameters: + - name: bad + in: bogus_location + required: true + schema: + type: string + responses: + '200': + description: ok diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..550951b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "strict": true, + "moduleResolution": "node", + "module": "CommonJS", + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["."], + "exclude": ["node_modules", "benchmark", "__test__"] +} diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..55f1062 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,27 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ + +# wrangler cache +.wrangler diff --git a/website/astro.config.mjs b/website/astro.config.mjs new file mode 100644 index 0000000..60f8976 --- /dev/null +++ b/website/astro.config.mjs @@ -0,0 +1,56 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; + +export default defineConfig({ + site: 'https://docs.openapi-ng.dev', + integrations: [ + starlight({ + title: 'openapi-ng', + favicon: './favicon.svg', + description: + 'Generate TypeScript models and Angular services from OpenAPI 3.x specs.', + social: [ + { + icon: 'github', + label: 'GitHub', + href: 'https://github.com/AVSystem/openapi-ng', + }, + { + icon: 'npm', + label: 'NPM', + href: 'https://www.npmjs.com/package/@avsystem/openapi-ng', + }, + ], + editLink: { + baseUrl: 'https://github.com/AVSystem/openapi-ng/edit/main/website/', + }, + sidebar: [ + { + label: 'Start here', + items: [ + { label: 'Introduction', slug: '' }, + { label: 'Getting started', slug: 'getting-started' }, + ], + }, + { + label: 'Guides', + items: [ + { label: 'CLI', slug: 'guides/cli' }, + { label: 'Configuration', slug: 'guides/configuration' }, + { label: 'Angular generator', slug: 'guides/angular' }, + ], + }, + { + label: 'Reference', + items: [ + { label: 'Node API', slug: 'reference/node-api' }, + { label: 'Diagnostics', slug: 'reference/diagnostics' }, + { label: 'Assumptions & limitations', slug: 'reference/limitations' }, + { label: 'Environment variables', slug: 'reference/environment' }, + { label: 'Runtime & platforms', slug: 'reference/runtime' }, + ], + }, + ], + }), + ], +}); diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..6d0aa4b --- /dev/null +++ b/website/package.json @@ -0,0 +1,22 @@ +{ + "name": "@avsystem/openapi-ng-website", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/starlight": "0.39.2", + "astro": "6.3.6", + "sharp": "0.34.5" + }, + "devDependencies": { + "wrangler": "4.93.1" + }, + "packageManager": "pnpm@10.33.3" +} diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml new file mode 100644 index 0000000..1f3e1f3 --- /dev/null +++ b/website/pnpm-lock.yaml @@ -0,0 +1,3993 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@astrojs/starlight': + specifier: 0.39.2 + version: 0.39.2(astro@6.3.6(@types/node@24.12.4)(rollup@4.60.4))(typescript@5.9.3) + astro: + specifier: 6.3.6 + version: 6.3.6(@types/node@24.12.4)(rollup@4.60.4) + sharp: + specifier: 0.34.5 + version: 0.34.5 + +packages: + + '@astrojs/compiler@4.0.0': + resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==} + + '@astrojs/internal-helpers@0.9.1': + resolution: {integrity: sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==} + + '@astrojs/markdown-remark@7.1.2': + resolution: {integrity: sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==} + + '@astrojs/mdx@5.0.6': + resolution: {integrity: sha512-4dKe0ZMmqujofPNDHahzClkwinn9f8jHPcaXcgdGvPAlboD2mjzkUCofli2cBnxYAkdfhC6d50gBJ8i/cH8gHw==} + engines: {node: '>=22.12.0'} + peerDependencies: + astro: ^6.0.0 + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/sitemap@3.7.2': + resolution: {integrity: sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==} + + '@astrojs/starlight@0.39.2': + resolution: {integrity: sha512-vlw+bwnjtf5buCTUtLU7JfV6D3knslxqnspr6LKs6hfRuFZiyr5hT44F7GyDqR9FKANUqFxnIzWM81F1k/kOUA==} + peerDependencies: + astro: ^6.0.0 + + '@astrojs/telemetry@3.3.2': + resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@capsizecss/unpack@4.0.0': + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expressive-code/core@0.42.0': + resolution: {integrity: sha512-MN11+9nfmaC7sYu2BZJXAXqwkBRt8t1xTSqP+Ti1NfTEskgl6xUnzDxoaiQkg0BMzpglA0pys4dpDKquP/cyIw==} + + '@expressive-code/plugin-frames@0.42.0': + resolution: {integrity: sha512-XtkPm+941Uta7Y+81Acv+OA/20F1NJmJhCX6UYGKpqEIGqplNh3PTOhcURp6tcruhlzJcWcvpWy6Oigz3SrjqA==} + + '@expressive-code/plugin-shiki@0.42.0': + resolution: {integrity: sha512-PMKey/kLmewttAHQezL+Y5Fx3vVssfDi3+FJOYQQS2mXP3tQspFELtKKAfsXfmSXdToZYgwoO69HJndqfE+09g==} + + '@expressive-code/plugin-text-markers@0.42.0': + resolution: {integrity: sha512-l59lUx8fq1v5g6SpmbDjiU0+7IdfbiWnAyRmtTVSpfhyq+nZMN4UcmYyu2b9Mynhzt7Gr+O+cXyEPDNb2AVWVQ==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.5.2': + resolution: {integrity: sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==} + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@shikijs/core@4.1.0': + resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.1.0': + resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.1.0': + resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.1.0': + resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.1.0': + resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.1.0': + resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} + engines: {node: '>=20'} + + '@shikijs/types@4.1.0': + resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + astro-expressive-code@0.42.0: + resolution: {integrity: sha512-aiTePi2Cn0mJPYWZSzP1GcxCinX9mNtJyCCshVVPSg1yRwM7ADvFJOx0FnS440M9t65hp8JH//dc2qr22Bm4ag==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta + + astro@6.3.6: + resolution: {integrity: sha512-lM30gGI/iASK9Z1WQVnBBYzxVwDv8slkXbJOF7FNJdZQeBrFETpsQvYoLRupM/adt2ObP5hkYAWEeCjofoqlRw==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.0: + resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expressive-code@0.42.0: + resolution: {integrity: sha512-V5DtJLEKuj4wf9O6IRtPtRObkMVy2ggR+S0MdjrTw6m58krZnDioyhW1si3Y04c5YPeooP4nd85Yq9NwEVHS4g==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + i18next@26.2.0: + resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.42.0: + resolution: {integrity: sha512-8rp/1YMEVVSYbtz+bFBx+uSx3vA4i4T8RwRm5Q/IWbucQnnQqQ0hDqtmKOr8tv+59Cik6cu5aH3WPo0I7csuTA==} + + rehype-format@5.0.1: + resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shiki@4.1.0: + resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stream-replace-string@2.0.0: + resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@0.1.12: + resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/compiler@4.0.0': {} + + '@astrojs/internal-helpers@0.9.1': + dependencies: + picomatch: 4.0.4 + + '@astrojs/markdown-remark@7.1.2': + dependencies: + '@astrojs/internal-helpers': 0.9.1 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + js-yaml: 4.1.1 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + retext-smartypants: 6.2.0 + shiki: 4.1.0 + smol-toml: 1.6.1 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/mdx@5.0.6(astro@6.3.6(@types/node@24.12.4)(rollup@4.60.4))': + dependencies: + '@astrojs/markdown-remark': 7.1.2 + '@mdx-js/mdx': 3.1.1 + acorn: 8.16.0 + astro: 6.3.6(@types/node@24.12.4)(rollup@4.60.4) + es-module-lexer: 2.1.0 + estree-util-visit: 2.0.0 + hast-util-to-html: 9.0.5 + piccolore: 0.1.3 + rehype-raw: 7.0.0 + remark-gfm: 4.0.1 + remark-smartypants: 3.0.2 + source-map: 0.7.6 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.2': + dependencies: + sitemap: 9.0.1 + stream-replace-string: 2.0.0 + zod: 4.4.3 + + '@astrojs/starlight@0.39.2(astro@6.3.6(@types/node@24.12.4)(rollup@4.60.4))(typescript@5.9.3)': + dependencies: + '@astrojs/markdown-remark': 7.1.2 + '@astrojs/mdx': 5.0.6(astro@6.3.6(@types/node@24.12.4)(rollup@4.60.4)) + '@astrojs/sitemap': 3.7.2 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.4 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 6.3.6(@types/node@24.12.4)(rollup@4.60.4) + astro-expressive-code: 0.42.0(astro@6.3.6(@types/node@24.12.4)(rollup@4.60.4)) + bcp-47: 2.1.0 + hast-util-from-html: 2.0.3 + hast-util-select: 6.0.4 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.2.0(typescript@5.9.3) + js-yaml: 4.1.1 + klona: 2.0.6 + magic-string: 0.30.21 + mdast-util-directive: 3.1.0 + mdast-util-to-markdown: 2.1.2 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + rehype: 13.0.2 + rehype-format: 5.0.1 + remark-directive: 4.0.0 + ultrahtml: 1.6.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@astrojs/telemetry@3.3.2': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@ctrl/tinycolor@4.2.0': {} + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@expressive-code/core@0.42.0': + dependencies: + '@ctrl/tinycolor': 4.2.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.15 + postcss-nested: 6.2.0(postcss@8.5.15) + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.42.0': + dependencies: + '@expressive-code/core': 0.42.0 + + '@expressive-code/plugin-shiki@0.42.0': + dependencies: + '@expressive-code/core': 0.42.0 + shiki: 4.1.0 + + '@expressive-code/plugin-text-markers@0.42.0': + dependencies: + '@expressive-code/core': 0.42.0 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + acorn: 8.16.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.16.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@oslojs/encoding@1.1.0': {} + + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + + '@rollup/pluginutils@5.3.0(rollup@4.60.4)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.4 + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@shikijs/core@4.1.0': + dependencies: + '@shikijs/primitive': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/primitive@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/types@4.1.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.13': {} + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 24.12.4 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.1': {} + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-iterate@2.0.1: {} + + astring@1.9.0: {} + + astro-expressive-code@0.42.0(astro@6.3.6(@types/node@24.12.4)(rollup@4.60.4)): + dependencies: + astro: 6.3.6(@types/node@24.12.4)(rollup@4.60.4) + rehype-expressive-code: 0.42.0 + + astro@6.3.6(@types/node@24.12.4)(rollup@4.60.4): + dependencies: + '@astrojs/compiler': 4.0.0 + '@astrojs/internal-helpers': 0.9.1 + '@astrojs/markdown-remark': 7.1.2 + '@astrojs/telemetry': 3.3.2 + '@capsizecss/unpack': 4.0.0 + '@clack/prompts': 1.4.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 1.1.1 + devalue: 5.8.1 + diff: 8.0.4 + dset: 3.1.4 + es-module-lexer: 2.1.0 + esbuild: 0.27.7 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.1.1 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + magicast: 0.5.3 + mrmime: 2.0.1 + neotraverse: 0.6.18 + obug: 2.1.1 + p-limit: 7.3.0 + p-queue: 9.3.0 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + rehype: 13.0.2 + semver: 7.8.0 + shiki: 4.1.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyclip: 0.1.12 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5 + vfile: 6.0.3 + vite: 7.3.3(@types/node@24.12.4) + vitefu: 1.1.3(vite@7.3.3(@types/node@24.12.4)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + boolbase@1.0.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@1.1.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + direction@2.0.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-module-lexer@2.1.0: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.16.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escape-string-regexp@5.0.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expressive-code@0.42.0: + dependencies: + '@expressive-code/core': 0.42.0 + '@expressive-code/plugin-frames': 0.42.0 + '@expressive-code/plugin-shiki': 0.42.0 + '@expressive-code/plugin-text-markers': 0.42.0 + + extend@3.0.2: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.2.0: {} + + i18next@26.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + inline-style-parser@0.2.7: {} + + iron-webcrypto@1.2.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@3.0.0: {} + + is-docker@4.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-plain-obj@4.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsonc-parser@3.3.1: {} + + klona@2.0.6: {} + + longest-streak@3.1.0: {} + + lru-cache@11.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.4: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.1.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-manager-detector@1.6.0: {} + + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prismjs@1.30.0: {} + + property-information@7.1.0: {} + + radix3@1.1.2: {} + + readdirp@5.0.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.42.0: + dependencies: + expressive-code: 0.42.0 + + rehype-format@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-format: 1.1.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + resolve-pkg-maps@1.0.0: {} + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + sax@1.6.0: {} + + semver@7.8.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.0 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shiki@4.1.0: + dependencies: + '@shikijs/core': 4.1.0 + '@shikijs/engine-javascript': 4.1.0 + '@shikijs/engine-oniguruma': 4.1.0 + '@shikijs/langs': 4.1.0 + '@shikijs/themes': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.12.4 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.0 + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + stream-replace-string@2.0.0: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tiny-inflate@1.0.3: {} + + tinyclip@0.1.12: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: + optional: true + + ufo@1.6.4: {} + + ultrahtml@1.6.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.16.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.0 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + util-deprecate@1.0.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.3(@types/node@24.12.4): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@7.3.3(@types/node@24.12.4)): + optionalDependencies: + vite: 7.3.3(@types/node@24.12.4) + + web-namespaces@2.0.1: {} + + which-pm-runs@1.1.0: {} + + xxhash-wasm@1.1.0: {} + + yargs-parser@22.0.0: {} + + yocto-queue@1.2.2: {} + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/website/src/content.config.ts b/website/src/content.config.ts new file mode 100644 index 0000000..6a7b7a0 --- /dev/null +++ b/website/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from 'astro:content'; +import { docsLoader } from '@astrojs/starlight/loaders'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/website/src/content/docs/getting-started.md b/website/src/content/docs/getting-started.md new file mode 100644 index 0000000..1e53192 --- /dev/null +++ b/website/src/content/docs/getting-started.md @@ -0,0 +1,68 @@ +--- +title: Getting started +description: Install openapi-ng and generate your first TypeScript models and Angular services from an OpenAPI spec. +--- + +## Install + +```bash +pnpm add -D @avsystem/openapi-ng +``` + +`openapi-ng` requires Node.js 18+. See [Runtime & platforms](/reference/runtime/) +for the full compatibility matrix. + +## Generate from the CLI + +```bash +openapi-ng generate --input petstore.openapi.yaml --output ./generated +``` + +Output: + +``` +✓ Generated 4 files from Petstore (3.0.3) + 1 path · 1 operation · 1 schema + + model.generated.ts + rest.model.ts + rest.util.ts + rest/pet.rest.generated.ts +``` + +The full flag list is on the [CLI page](/guides/cli/). + +## Generate from Node + +```js +import { generate } from '@avsystem/openapi-ng'; + +const result = await generate({ + inputPath: './petstore.openapi.yaml', + outputPath: './generated', + emit: ['models', 'angular'], +}); + +console.log(result.summary); +console.log(result.diagnostics); +console.log(result.artifacts); +``` + +Omit `outputPath` to keep the result entirely in memory. + +## What you get + +| Artifact | File | Description | +|-------------------|---------------------------------|------------------------------------------------------| +| TypeScript models | `model.generated.ts` | Interfaces, type aliases, and string enum unions | +| Angular support | `rest.model.ts`, `rest.util.ts` | HTTP helper types and request utilities | +| Angular services | `rest/{tag}.rest.generated.ts` | `@Injectable` service classes grouped by OpenAPI tag | + +See [Angular generator](/guides/angular/) for the shape of the emitted +services and example component usage. + +## Next steps + +- Tune naming and mapped types on the [Configuration](/guides/configuration/) page. +- Walk through the [Angular generator](/guides/angular/) to see what the services look like and how to consume them. +- Skim the [Diagnostics reference](/reference/diagnostics/) so error codes don't surprise you later. diff --git a/website/src/content/docs/guides/angular.md b/website/src/content/docs/guides/angular.md new file mode 100644 index 0000000..a37ffb9 --- /dev/null +++ b/website/src/content/docs/guides/angular.md @@ -0,0 +1,380 @@ +--- +title: Angular generator +description: What the Angular generator emits — services, the three operation flavors (observable, resource, request), typed parse, base-path wiring, and non-JSON response variants. +--- + +## Models + +Schemas become typed TypeScript — interfaces for objects, type aliases +for references, and multi-line string unions for enums: + +```ts +export interface Pet { + id: PetId; + name: string; + nickname?: string; + status: PetStatus; + tags: Tag[]; +} + +export type PetId = string; + +export type PetList = Pet[]; + +export type PetStatus = + | 'available' + | 'pending' + | 'sold'; +``` + +Properties are sorted alphabetically. Optional fields get `?`. All +output is deterministic. + +## Services + +Each OpenAPI tag becomes an `@Injectable` Angular service. Every +operation is a readonly property with three flavors: + +- **`.observable(req, options?)`** — returns an `Observable` via `HttpClient`. Pass `{ observe: 'response' }` to receive `Observable>` (headers, status), or `{ observe: 'events' }` for `Observable>` (upload/download progress, raw events). See [`.observable()` modes](#observable-modes) below. +- **`.resource(reactiveReq, options?)`** — returns an `HttpResourceRef` via Angular's `httpResource`. The ref exposes `headers()`, `statusCode()`, and `progress()` as signals. +- **`.request(req)`** — returns the raw `CommonRequest` for custom use + +```ts +@Injectable({ + providedIn: 'root', +}) +export class PetRest { + readonly listPets = requestFactory.zeroArg( + () => ({ + method: 'GET', + url: `/pets`, + }), + ); + + readonly getPet = requestFactory( + (request: GetPetParams) => { + const { petId } = request; + return { + method: 'GET', + url: `/pets/${encodeURIComponent(petId)}`, + }; + }, + ); + + readonly updatePet = requestFactory( + (request: UpdatePetParams) => { + const { petId, includeHistory, body } = request; + return { + method: 'POST', + url: `/pets/${encodeURIComponent(petId)}`, + params: httpParams({ includeHistory }), + body: body, + }; + }, + ); +} +``` + +Operations with no request parameters use `requestFactory.zeroArg` (or +`.zeroArg.blob` / `.zeroArg.text` / `.zeroArg.arrayBuffer` for non-JSON +responses); parameterised operations use the top-level +`requestFactory` (with the same `.blob` / `.text` / +`.arrayBuffer` siblings). `HttpClient` is injected internally by the +runtime — services do not declare it. + +### Request interface shape + +Path params and query params always sit at the top of the request +interface. The body's surface follows a smart-flatten rule keyed on +how the spec author wrote the schema: + +- **Named `$ref` body** — preserved as a single nested `body: RefName` + field so the spec's named type stays referenceable from your code + (`UpdatePetParams['body']` is the imported `UpdatePetRequest`). +- **Inline `type: object` body** — hoisted to top-level fields next to + path/query, matching the spec's authorial signal of "these are just + parameters, not a named DTO". +- **Form bodies** (`multipart/form-data`, `application/x-www-form-urlencoded`) + — always hoist their fields to top-level; binary fields surface as + `Blob | File`. The generated builder materializes them into + `FormData` / `URLSearchParams` at runtime. +- **Scalar/array JSON bodies** — kept nested as `body: T` (there is no + property structure to hoist). + +```ts +// `updatePet` — body is `$ref: UpdatePetRequest`, so it nests. +export interface UpdatePetParams { + petId: PetId; // path param + includeHistory?: boolean; // query param + body: UpdatePetRequest; // ref body, nested +} + +// `decide` — body is an inline `{ csvImportId, doImport }` object, so +// its properties hoist to top-level. +export interface DecideParams { + csvImportId: CsvImportId; + doImport: boolean; +} +``` + +The escape hatch in either direction is in the spec: hoist an inline +body to `components/schemas` (give it a name) to switch to a nested +`body`, or inline a named schema's contents at the operation site to +hoist its properties. Hoisted properties that collide with a path or +query parameter name are rejected at codegen with +`E_POLICY_VIOLATION` / `field-collision` — rename the offender or +hoist the body schema to a `$ref` so it nests under `body` instead. + +## Configuring the base path + +`rest.util.ts` exports an `OPENAPI_NG_BASE_PATH` injection token and a +matching `provideOpenapiNg` helper. Provide it once at bootstrap and +every service prepends it to the generated relative URL automatically +(the prefix is joined with a single `/`, regardless of trailing/leading +slashes): + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideOpenapiNg } from './generated/rest.util'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient(), + provideOpenapiNg({ basePath: 'https://api.example.com' }), + ], +}; +``` + +Skip the provider and requests fall back to the spec-relative URL +(`/pets`, `/pets/{id}`, …) — useful in dev with a proxy. + +## `.resource()` — typed `httpResource` + +Generated services delegate to Angular's +[`httpResource`](https://angular.dev/api/common/http/httpResource), so +**`.resource()` accepts every option `httpResource` accepts** — request +transforms, equality, injector, and so on. The generator only adds +strong typing on top. + +The signature uses overloads keyed on whether you pass `defaultValue` +and/or `parse`: + +| Options | Return type | +|-------------------------------|-----------------------------------| +| *(none)* | `HttpResourceRef` | +| `{ defaultValue }` | `HttpResourceRef` | +| `{ parse }` | `HttpResourceRef` | +| `{ defaultValue, parse }` | `HttpResourceRef` | + +…where `T` is the spec-declared response type and `U` is whatever +`parse` returns. + +### Why `parse` matters here + +Angular's stock `httpResource(req, { parse })` types `parse` +as `(raw: unknown) => TResult` — you take an `unknown` blob and prove +it's `TResult`. The generated `.resource()` does better: **`raw` is +typed as the spec's response type**, so `parse` becomes an honest +transformation rather than a runtime cast. + +```ts +import { PetRest } from './generated/rest/pet.rest.generated'; +import type { Pet } from './generated/model.generated'; + +@Component({ /* ... */ }) +export class PetList { + readonly #petRest = inject(PetRest); + + // raw: Pet, return: PetSummary — both fully typed, no `as` needed. + protected readonly summary = + this.#petRest.getPet.resource( + () => ({ petId: this.selectedId() }), + { + defaultValue: { id: '', label: '—' } satisfies PetSummary, + parse: (raw) => ({ id: raw.id, label: raw.name }), + }, + ); +} + +interface PetSummary { id: string; label: string } +``` + +Pass any of the standard `httpResource` options the same way — for +example a `defaultValue`, an `equal` comparator, or an `injector`: + +```ts +this.#petRest.listPets.resource({ + defaultValue: [], + equal: (a, b) => a.length === b.length, +}); +``` + +### Reactive request gating + +The reactive request callback may return `undefined` to skip the call +(matching `httpResource`'s convention): + +```ts +this.#petRest.getPet.resource( + () => this.selectedId() ? { petId: this.selectedId() } : undefined, +); +``` + +### Response metadata via the ref + +`HttpResourceRef` exposes the response envelope as Angular signals on +the returned ref — no observe/response juggling required: + +```ts +protected readonly petResource = + this.#petRest.getPet.resource(() => ({ petId: this.selectedId() })); + +// Read inside an effect, computed, or template binding: +this.petResource.headers(); // Signal +this.petResource.statusCode(); // Signal +this.petResource.progress(); // Signal +``` + +Use these for download progress UI, status-code-driven branching, or +reading caching/correlation headers without dropping out of the +signal-based flow. + +## `.observable()` modes + +`.observable(req)` returns `Observable` by default — the response +body, decoded according to the operation's content type. Pass an +options object to switch observation modes or to forward extra +`HttpClient.request` configuration: + +```ts +this.#petRest.updatePet.observable(req); +this.#petRest.updatePet.observable(req, { observe: 'response' }); +this.#petRest.updatePet.observable(req, { observe: 'events', reportProgress: true }); +``` + +| Options | Return type | +|-------------------------------|-----------------------------------| +| *(none)* / `{ observe: 'body' }` | `Observable` | +| `{ observe: 'response' }` | `Observable>` | +| `{ observe: 'events' }` | `Observable>` | + +The options bag mirrors `HttpClient.request`'s options minus the +fields the generator already supplies — `body`, `params`, `headers`, +and `responseType` are baked in from the operation, so the type +rejects them. Everything else is forwarded: `withCredentials`, +`reportProgress`, `transferCache`, `context`, `keepalive`, and the +Fetch-related options (`redirect`, `mode`, `credentials`, `priority`, +`cache`, `timeout`). + +For void-response operations (204 No Content), the same overloads +still apply — `Observable>` is meaningful when you +need `Location`, `ETag`, or trace headers from a `POST` / `PUT` / +`DELETE`. + +## Using a service + +```ts +@Component({ /* ... */ }) +export class PetList { + readonly #petRest = inject(PetRest); + + // As an Observable + protected readonly pets$ = + this.#petRest.listPets.observable(); + + // As an HttpResource (reactive, signal-based) + protected readonly petsResource = + this.#petRest.listPets.resource({ + defaultValue: [], + }); + + // With parameters + protected readonly petResource = + this.#petRest.getPet.resource( + () => ({ petId: this.selectedId() }), + ); + + // Imperative call + protected update(petId: PetId) { + this.#petRest.updatePet.observable({ + petId, + status: 'sold', + tagIds: [1, 2], + }).subscribe(); + } + + // Raw CommonRequest — for custom transports, logging, etc. + protected debug(petId: PetId) { + const req = this.#petRest.getPet.request({ petId }); + console.log(req.method, req.url, req.params); + } +} +``` + +## Non-JSON responses + +When an operation declares a response content type other than JSON, +the generator emits the same three flavors but routes them through +the matching `httpResource` factory. The `requestFactory` symbol +itself is a callable for JSON, with sibling factories for the other +kinds: + +| Response kind | Factory | Emitted return type | +|---------------|-------------------------------|-------------------------------| +| `json` | `requestFactory(...)` | `Observable` / `HttpResourceRef` | +| `blob` | `requestFactory.blob(...)` | `Observable` / `HttpResourceRef` | +| `text` | `requestFactory.text(...)` | `Observable` / `HttpResourceRef` | +| `arrayBuffer` | `requestFactory.arrayBuffer(...)` | `Observable` / `HttpResourceRef` | + +The picker uses the response's declared content type; you can override +the mapping per content type with the `responseTypeMapping` option (CLI: +not currently exposed; Node: `generate({ responseTypeMapping: [...] })`). + +## What `rest.model.ts` / `rest.util.ts` ship + +The generator emits two hand-written helper files alongside the +service. They are stable, dependency-free, and worth knowing because +your code can import from them directly. + +### `rest.model.ts` + +Type-only module. Defines: + +- `QueryParamValue` — the value types `httpParams` accepts + (`string | number | boolean | ReadonlyArray<…>`). +- `CommonRequest` — the raw request returned by `.request()`. Extends + Angular's `HttpResourceRequest` and adds `method` / `url`. Use this + for custom transports. +- `WithDefault`, `WithParse` — mixin shapes for resource + options. +- `BaseHttpResourceOptions` — `HttpResourceOptions` with + `parse` and `defaultValue` stripped out (so the generator can put + back better-typed versions). +- `BaseHttpResourceOptionsWithDefault` / + `…WithParse` / `…WithDefaultAndParse` — the four + permutations consumed by the `.resource()` overloads. +- `HttpResourceOptionsUnion` — union over all four. + +### `rest.util.ts` + +Runtime module. Exports: + +- `OPENAPI_NG_BASE_PATH` — `InjectionToken` for the API base. +- `provideOpenapiNg({ basePath })` — `EnvironmentProviders` shortcut. +- `httpParams(record)` — builds an `HttpParams` from a record, + skipping `undefined` and flattening arrays (each item becomes a + repeated query param). +- `RequestFn` / `ZeroArgRequestFn` — the + shape of every generated operation property (generic parameters are + `Request` first, `Response` second); `RequestFnVoid` / + `ZeroArgRequestFnVoid` are the void-response variants picked + automatically when `Response` is `void`. +- `requestFactory` — callable for JSON; `.blob` / `.text` / + `.arrayBuffer` cover the non-JSON response kinds. + +## Assumptions and limitations + +`openapi-ng` accepts a focused subset of OpenAPI 3.x. See the +[Limitations reference](/reference/limitations/) for the full list of +required fields, supported schema shapes, and what's out of scope. diff --git a/website/src/content/docs/guides/cli.md b/website/src/content/docs/guides/cli.md new file mode 100644 index 0000000..3c8b220 --- /dev/null +++ b/website/src/content/docs/guides/cli.md @@ -0,0 +1,56 @@ +--- +title: CLI +description: Reference for the openapi-ng command-line interface — generate and init commands, their flags, and URL inputs. +--- + +The `openapi-ng` binary exposes two top-level commands: `generate` and +`init`. + +## `openapi-ng generate` + +``` +openapi-ng generate --input [options] +``` + +| Flag | Short | Description | +|------------------------|-------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--input ` | `-i` | Path to an OpenAPI 3.x JSON or YAML file, or an `https://` URL (required). See [URL inputs](#url-inputs) below. | +| `--output ` | `-o` | Output directory. Omit to generate in memory without writing files | +| `--emit ` | | Comma-separated emit list: `models,angular` (repeatable). Default: `models,angular`. `'angular'` auto-includes `'models'`. | +| `--verbose` | | Print warning diagnostics with codes | +| `--mapped-type ` | | Replace a schema with an external type import (repeatable). Format: `schema:import:type[:alias]` — see [Mapped types](/guides/configuration/#mapped-type-spec-format). | +| `--config ` | `-c` | Path to a config file (overrides auto-discovery — see [Configuration](/guides/configuration/)) | + +### URL inputs + +`--input` (and the `input` config key, and `generate({ inputPath })`) +accept an `https://` URL alongside local file paths: + +```bash +openapi-ng generate -i https://example.com/openapi.yaml -o ./src/generated +``` + +Only `https://` is supported — `http://` and other schemes are +rejected. Redirects are followed up to 5 hops and must stay on +`https://`. The fetched body counts against the same input cap that +applies to local files (`OPENAPI_NG_MAX_INPUT_BYTES`, default 16 MiB). +The fetch wall-clock timeout defaults to 30 s and is configurable via +`OPENAPI_NG_INPUT_TIMEOUT_MS`. See [Environment variables](/reference/environment/) +for both knobs. + +## `openapi-ng init` + +``` +openapi-ng init [--format yaml|json|ts|js] +``` + +Writes a starter config file in the current directory. Default format +is `yaml`. `--format ts` writes `openapi-ng.config.mts` (with a +`defineConfig` import and a commented `naming.parse` RegExp example); +`--format js` writes `openapi-ng.config.mjs`. Both pick the `.m*` +extension on purpose: it forces ESM, avoiding Node's CJS-first parse ++ ESM-reparse penalty under a typeless `package.json`, and keeps the +config independent of `package.json#type` mutations. + +Aborts (no overwrite) if any of the eight discoverable config files +already exists in the current directory. diff --git a/website/src/content/docs/guides/configuration.md b/website/src/content/docs/guides/configuration.md new file mode 100644 index 0000000..99bb803 --- /dev/null +++ b/website/src/content/docs/guides/configuration.md @@ -0,0 +1,318 @@ +--- +title: Configuration +description: How openapi-ng discovers config files, the JS/TS config format, mapped types, and naming customization. +--- + +## Discovery + +`openapi-ng` looks for a config file in the working directory and each +parent directory until it finds one. Supported formats, in priority +order: + +- `openapi-ng.config.ts` / `.mts` / `.cts` (TypeScript — requires Node 22.6+; + prefer `.mts` to avoid the CJS-first parse penalty under a typeless + `package.json`) +- `openapi-ng.config.mjs` / `.js` / `.cjs` +- `.openapi-ng.yaml` (legacy dotfile, parsed as YAML) +- `.openapi-ng.json` (legacy dotfile, parsed as JSON) + +A few naming rules worth knowing: + +- **Discovery is case-sensitive on Linux.** Keep the filename lowercase + or it will not be found on Linux. +- **macOS and Windows** typically run on case-insensitive volumes, so a + mixed-case file may be discovered there but not on Linux. Keep the + name lowercase to stay portable. + +Pass `--config ` (or `-c `) to point at a file explicitly +and skip discovery. + +Config files are loaded from the local filesystem; do not point +`--config` at an untrusted YAML or JS/TS file. JS/TS configs execute +arbitrary code at load time, and even the YAML parser, while +safe-by-default today, sits inside an implicit trust boundary worth +respecting. + +## JS/TS configs + +A `openapi-ng.config.ts` (or `.js`/`.mjs`/`.cjs`) lets you use +JavaScript features the YAML/JSON formats cannot express — most +importantly, real `RegExp` literals in `naming.parse`: + +```ts +import { defineConfig } from '@avsystem/openapi-ng/config'; + +export default defineConfig({ + input: './petstore.openapi.yaml', + output: './src/generated', + emit: ['models', 'angular'], + mappedTypes: [ + { schema: 'DateTime', import: 'dayjs', type: 'Dayjs' }, + ], + naming: { + methodName: { + from: '{operationId}', + parse: /^[^_]+_(?.+)$/, + format: '{capture.rest}', + case: 'camel', + }, + group: [{ format: '{tags[0]}', case: 'pascal' }], + }, +}); +``` + +The default export may be an object or an async function returning one. +`defineConfig` is an identity helper for TypeScript inference; using it +is optional. + +TypeScript files (`.ts`/`.mts`/`.cts`) require Node 22.6+ (or 23.6+ for +the flag-free default). On older Node, use `.js`/`.mjs` with JSDoc +types: `/** @type {import('@avsystem/openapi-ng').Config} */`. + +Run `openapi-ng init --format ts` (or `json`/`js`) to scaffold a +starter file. + +## Mapped types + +Some real-world types are awkward to express in OpenAPI Schema — +think GeoJSON `Feature`/`FeatureCollection`, recursive AST nodes, +or types that already live in a well-typed npm package you'd rather +re-use than re-describe. + +Mapped types let you ship a *placeholder* schema in the spec and +swap it for an external TypeScript type at generation time. For +GeoJSON, instead of trying (and failing) to translate the full +GeoJSON RFC into nested `oneOf`/discriminator schemas, you declare +a stub: + +```yaml +# petstore.openapi.yaml +components: + schemas: + GeoFeature: + type: object # placeholder — replaced via mapped type +``` + +…then point the generator at the real type: + +```bash +openapi-ng generate \ + --input petstore.openapi.yaml \ + --output ./generated \ + --mapped-type GeoFeature:geojson:Feature +``` + +The generated `model.generated.ts` now imports the real type and uses +it everywhere `GeoFeature` was referenced: + +```ts +import type { Feature } from 'geojson'; + +export type GeoFeature = Feature; + +// ...wherever GeoFeature was used, it's now `Feature`. +``` + +### Mapped-type spec format + +CLI: `--mapped-type schema:import:type[:alias]` + +| Position | Field | Meaning | +|----------|----------|---------------------------------------------------------------| +| 1 | `schema` | Schema name from `components.schemas` to replace | +| 2 | `import` | Module specifier to import from | +| 3 | `type` | Named export to import | +| 4 | `alias` | Optional. Rename to avoid local collisions (`Feature as Geo`) | + +The same shape works in JS/TS/YAML/JSON configs: + +```ts +mappedTypes: [ + { schema: 'GeoFeature', import: 'geojson', type: 'Feature' }, + { schema: 'GeoFeatureCollection', import: 'geojson', type: 'FeatureCollection' }, + { schema: 'DateTime', import: 'dayjs', type: 'Dayjs' }, + { schema: 'BigDecimal', import: 'decimal.js', type: 'Decimal', alias: 'BigDecimal' }, +] +``` + +## Response type mapping + +By default the generator picks the request flavor from the operation's +declared response content type: `application/json` → JSON, +`application/octet-stream` → Blob, `text/*` → text, and so on. Override +the picker per content type with `responseTypeMapping` when the spec +under-declares the wire format (e.g. a route returns +`application/pdf` but is declared as JSON): + +```ts +responseTypeMapping: [ + { contentType: 'application/pdf', responseType: 'blob' }, + { contentType: 'application/x-ndjson', responseType: 'text' }, +]; +``` + +| Field | Meaning | +|----------------|----------------------------------------------------------------------------------------------------------| +| `contentType` | Response media type to override (matched case-insensitively against the operation's declared responses). | +| `responseType` | One of `json`, `blob`, `text`, `arrayBuffer` — mirrors Angular's `HttpClient.request({ responseType })`. | + +The matched operation is wired through the corresponding +`requestFactory.*` variant (see [Non-JSON responses](/guides/angular/#non-json-responses)). +This option is **not** exposed on the CLI; configure it via a config +file or the Node API. + +## Customizing names + +By default, openapi-ng derives names like this: + +| Key | Default chain | +|--------------|-----------------------------------------------------------------------------------| +| `methodName` | `camelCase(operationId)`, else `camelCase(method + '_' + pathSegments.join('_'))` | +| `group` | `pascalCase(tags[0])`, else `pascalCase(pathSegments[0])`, else `'Default'` | + +These run unless you set the corresponding `naming.methodName` / +`naming.group` config. **Both keys support fallback chains** — a chain +runs each rule in order until one succeeds. + +### Rule shape + +```ts +{ + from?: string, // template expanded to the parser input + parse?: RegExp, // optional; named captures populate {capture.*} + format?: string, // template expanded to the final name + case?: 'camel' | 'pascal' | 'snake' | 'kebab' | 'constant', +} +``` + +A rule fails when: + +- Any `{token}` in `from` or `format` is unbound (e.g. `{operationId}` + on an operation with no `operationId`, or `{x-foo}` when the + extension isn't present). +- `parse` is set and the regex doesn't match the expanded `from`. +- Either template is malformed (unclosed `{`, bad index syntax). + +If every rule in a chain fails, the generator throws +`E_POLICY_VIOLATION` with `subcode: 'naming-resolution'`. + +### Template tokens + +Both `from` and `format` accept the same tokens: + +| Token | Resolves to | +|---------------------|------------------------------------------------------------------------------------------------| +| `{operationId}` | The OpenAPI `operationId`. Unbound when missing. | +| `{method}` | HTTP method, lowercased (`get`, `post`, …). | +| `{path}` | The full path (e.g. `/users/{id}`). | +| `{pathSegments[N]}` | Nth path segment (0-indexed). Brace path params are unwrapped — `/users/{id}` → `users`, `id`. | +| `{tags[N]}` | Nth tag. | +| `{x-}` | Vendor extension on the operation. *Not yet plumbed through normalize — always unbound today.* | +| `{capture.}` | Named capture from `parse` (`(?...)`). Only available inside `format`. | + +Array tokens accept **negative indexes** that count from the tail: +`{tags[-1]}` is the last tag, `{pathSegments[-1]}` the deepest segment. + +### `case` values + +Five options. The transformer first splits the input into tokens +(splitting on case boundaries, separators, and digit/letter +transitions), then rejoins: + +| Case | Input `get_someThing` | +|------------|-----------------------| +| `camel` | `getSomeThing` | +| `pascal` | `GetSomeThing` | +| `snake` | `get_some_thing` | +| `kebab` | `get-some-thing` | +| `constant` | `GET_SOME_THING` | + +Values are lowercase per spec — `'Camel'` does not parse. + +### Forms + +A `NamingValue` can be one of three shapes: + +```ts +// 1. Shorthand: a bare format template. No case transform. +methodName: '{operationId}' + +// 2. Single rule. +methodName: { + from: '{operationId}', + parse: /^[^_]+_(?.+)$/, + format: '{capture.rest}', + case: 'camel', +} + +// 3. Chain: try each entry in order, stop on first success. +methodName: [ + // Prefer the vendor extension if the spec sets one. + { from: '{x-method-name}', format: '{x-method-name}', case: 'camel' }, + // Strip a verb prefix from operationId: `posts_listAll` → `listAll`. + { + from: '{operationId}', + parse: /^[^_]+_(?.+)$/, + format: '{capture.rest}', + case: 'camel', + }, + // Plain operationId. + '{operationId}', +] +``` + +### Examples + +**Strip a prefix from `operationId`** + +```ts +naming: { + methodName: { + from: '{operationId}', + parse: /^[^_]+_(?.+)$/, + format: '{capture.rest}', + case: 'camel', + }, +} +// posts_listAll → listAll +``` + +**Group by a vendor extension, fall back to the first tag** + +```ts +naming: { + group: [ + { format: '{x-resource-group}', case: 'pascal' }, + { format: '{tags[0]}', case: 'pascal' }, + ], +} +``` + +**Build a method name from method + last path segment** + +```ts +naming: { + methodName: { + from: '{method}_{pathSegments[-1]}', + format: '{method}_{pathSegments[-1]}', + case: 'camel', + }, +} +// GET /users/{id} → getId ⚠ may collide; chain a fallback in real use +``` + +**Force kebab-case file groups** + +```ts +naming: { + group: { format: '{tags[0]}', case: 'kebab' }, +} +// tag "PetOrders" → pet-orders +``` + +### YAML/JSON limitation + +YAML and JSON configs accept the same shape, with one limitation: the +`parse` field requires a JavaScript `RegExp` and cannot be expressed +in YAML/JSON. Use an `openapi-ng.config.{ts,js,mjs,cjs}` config file +when you need `parse` rules. diff --git a/website/src/content/docs/index.mdx b/website/src/content/docs/index.mdx new file mode 100644 index 0000000..11a790d --- /dev/null +++ b/website/src/content/docs/index.mdx @@ -0,0 +1,59 @@ +--- +title: openapi-ng +description: Generate TypeScript models and Angular services from OpenAPI 3.x specs. +template: splash +hero: + tagline: Generate TypeScript models and Angular services from OpenAPI 3.x specs — fast, deterministic, Rust-powered. + actions: + - text: Get started + link: /getting-started/ + icon: right-arrow + - text: View on GitHub + link: https://github.com/AVSystem/openapi-ng + icon: external + variant: minimal +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + +## Why openapi-ng + + + + The engine is a native binary loaded via [NAPI-RS](https://napi.rs). + The same input always produces identical output. + + + Each operation comes with three flavors — `.observable()`, + `.resource()`, and `.request()` — matching Angular's current HTTP + primitives. + + + A focused 3.x subset with clear diagnostics. No silent + misgeneration. + + + Tune method names and service grouping with template + regex rules, + via YAML, JSON, or TypeScript config. + + + +## Install + +```bash +# pnpm +pnpm add -D @avsystem/openapi-ng + +# npm +npm install -D @avsystem/openapi-ng + +# bun +bun add -d @avsystem/openapi-ng + +# yarn +yarn add -D @avsystem/openapi-ng +``` + +Requires Node.js 18+. Pre-built binaries are published for macOS, Linux, +and Windows (x64 and ARM64), with a WASI fallback for other hosts. See +[Runtime & platforms](/reference/runtime/). diff --git a/website/src/content/docs/reference/diagnostics.md b/website/src/content/docs/reference/diagnostics.md new file mode 100644 index 0000000..371eae2 --- /dev/null +++ b/website/src/content/docs/reference/diagnostics.md @@ -0,0 +1,111 @@ +--- +title: Diagnostics +description: Diagnostic codes, subcodes, and the GenerateError class — what fires, when, and what to check. +--- + +## Diagnostic codes + +```ts +type DiagnosticCode = + | 'E_INPUT_INVALID' + | 'E_INVALID_OPTION' + | 'E_INVALID_REFERENCE' + | 'E_POLICY_VIOLATION' + | 'E_UNEXPECTED' + | 'E_UNSUPPORTED_RUNTIME' + | 'E_UNSUPPORTED_SEMANTIC' + | 'E_WRITE_FAILED'; +``` + +| Code | When | What to check | +|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `E_INPUT_INVALID` | The input file couldn't be read, decoded as JSON/YAML, or exceeds `OPENAPI_NG_MAX_INPUT_BYTES`. | File path, file extension (`.json`/`.yaml`/`.yml`), JSON/YAML syntax (the message includes line:column when the parser supplies it), and the input-bytes cap — see [Environment variables](/reference/environment/). | +| `E_INVALID_OPTION` | Caller passed an invalid option. Subcode `'shape'` is a wrapper-side type/shape rejection; no subcode means the Rust core rejected an option's value semantically. | Compare your `generate()` call against `index.d.ts`; branch on `err.subcode === 'shape'` when the distinction matters. | +| `E_INVALID_REFERENCE` | A `$ref` doesn't resolve or points outside `#/components/schemas/...`. | Typos in `$ref`, external-file refs (not supported), refs to other components sections, cycles in non-schema positions. | +| `E_POLICY_VIOLATION` | An IR-level rule is broken. | Inspect `err.subcode` to route. Cap-exceeded subcodes correlate with the env-var knobs (see [Environment variables](/reference/environment/)); the `multipart-*` / `urlencoded-*` subcodes pin the precise reject path for the form-body walkers. | +| `E_UNEXPECTED` | A Rust panic crossed the NAPI boundary. The library swallows the panic and surfaces it as an error so your Node process keeps running. | Open an issue with the message — this should never fire on valid input. | +| `E_UNSUPPORTED_RUNTIME` | The package was loaded in a non-Node runtime (browser bundle, Cloudflare Workers, Vercel Edge, etc.) and `generate()` was called on the `browser.js` stub. | Run the generator from Node only; remove `@avsystem/openapi-ng` from browser bundles or alias it away. See [Runtime & platforms](/reference/runtime/). | +| `E_UNSUPPORTED_SEMANTIC` | The spec uses an OpenAPI shape outside the supported subset. | See the [Angular generator](/guides/angular/) limitations. Warn-level by default; if blocking, switch to a supported shape. | +| `E_WRITE_FAILED` | An output file write failed. | Permissions on `outputPath`, disk space, parent-directory existence. | + +## Subcodes + +```ts +type DiagnosticSubcode = + | 'shape' + | 'duplicate-operation-id' + | 'duplicate-schema-name' + | 'field-collision' + | 'format-dropped' + | 'mapping-expansion-exceeded' + | 'missing-discriminator-property' + | 'missing-operation-id' + | 'missing-tag' + | 'multi-content-body' + | 'multipart-composed-field' + | 'multipart-nested-object' + | 'multipart-non-object-body' + | 'multipart-open-schema' + | 'naming-resolution' + | 'operation-cap-exceeded' + | 'schema-cap-exceeded' + | 'unsupported-body-content-type' + | 'unsupported-parameter-location' + | 'urlencoded-binary-field' + | 'urlencoded-composed-field' + | 'urlencoded-nested-object' + | 'urlencoded-non-object-body' + | 'urlencoded-open-schema'; +``` + +Subcodes are populated for routable diagnostics +(`E_POLICY_VIOLATION`, select `E_UNSUPPORTED_SEMANTIC` warnings, and +`E_INVALID_OPTION` wrapper-side shape rejections); they are `null` +otherwise. + +`E_INVALID_OPTION` is dual-sourced: the JS wrapper raises it with +`subcode: 'shape'` for pre-flight type/shape failures (e.g. `emit must +be an array of EmitTarget`); the Rust core raises it with no subcode +for semantic rejections of an option's value. Branch on +`err.subcode === 'shape'` if the distinction matters. + +## `GeneratorDiagnostic` + +```ts +interface GeneratorDiagnostic { + code: DiagnosticCode; + subcode: DiagnosticSubcode | null; + severity: 'error' | 'warning'; + message: string; + path: string; +} +``` + +`generate()` returns warnings in `result.diagnostics` on success. On +fatal failure it throws a `GenerateError` whose `warnings` field +contains any pre-fatal warnings. + +## `GenerateError` + +```ts +class GenerateError extends Error { + readonly code: DiagnosticCode; // e.g. 'E_INPUT_INVALID' + readonly subcode: DiagnosticSubcode | null; // finer-grained routing key + readonly path: string; // input path (empty when not applicable) + readonly warnings: Array; // pre-fatal warnings (empty when none) +} +``` + +```ts +import { GenerateError, generate } from '@avsystem/openapi-ng'; + +try { + generate({ inputPath: 'spec.yaml', emit: ['models', 'angular'] }); +} catch (err) { + if (err instanceof GenerateError) { + console.error(`[${err.code}] ${err.message}`); + if (err.subcode) console.error(` subcode: ${err.subcode}`); + if (err.warnings.length) console.warn(err.warnings); + } +} +``` diff --git a/website/src/content/docs/reference/environment.md b/website/src/content/docs/reference/environment.md new file mode 100644 index 0000000..687070c --- /dev/null +++ b/website/src/content/docs/reference/environment.md @@ -0,0 +1,67 @@ +--- +title: Environment variables +description: Generator caps that protect against pathological inputs — input size, fetch timeout, schema/operation counts, YAML expansion ratio, and the SSRF guard escape hatch. +--- + +Five optional caps guard the generator against pathological inputs +(e.g. an accidentally fanned-out YAML anchor or a multi-megabyte spec) +before any heavy work runs. Defaults are deliberately generous — real +specs sit several orders of magnitude below — and all five accept a +positive integer override. A sixth variable opts out of the URL-fetch +SSRF guard for local/test workflows. + +| Variable | Default | Effect on overflow | +|----------------------------------|---------------------|-------------------------------------------------------------| +| `OPENAPI_NG_MAX_INPUT_BYTES` | `16777216` (16 MiB) | `E_INPUT_INVALID` before parsing | +| `OPENAPI_NG_INPUT_TIMEOUT_MS` | `30000` (30 s) | URL fetch aborts | +| `OPENAPI_NG_MAX_SCHEMAS` | `10000` | `E_POLICY_VIOLATION` / `schema-cap-exceeded` | +| `OPENAPI_NG_MAX_OPERATIONS` | `10000` | `E_POLICY_VIOLATION` / `operation-cap-exceeded` | +| `OPENAPI_NG_MAX_EXPANSION_RATIO` | `50` | `E_POLICY_VIOLATION` / `mapping-expansion-exceeded` | +| `OPENAPI_NG_ALLOW_PRIVATE_HOSTS` | *unset* | When set to `1`, disables the SSRF guard on `--input ` | + +## Per-variable detail + +### `OPENAPI_NG_MAX_INPUT_BYTES` + +Maximum input size in bytes (file, URL, or inline `inputContents`). +Inputs that exceed the cap fail with `E_INPUT_INVALID` before parsing. + +### `OPENAPI_NG_INPUT_TIMEOUT_MS` + +Wall-clock cap on URL fetches. Applies to the total time including +redirects. + +### `OPENAPI_NG_MAX_SCHEMAS` + +Maximum number of entries allowed under `components.schemas`. +Exceeding the cap fails with `E_POLICY_VIOLATION` and subcode +`schema-cap-exceeded`. + +### `OPENAPI_NG_MAX_OPERATIONS` + +Maximum total number of operations summed across all paths. Exceeding +the cap fails with `E_POLICY_VIOLATION` and subcode +`operation-cap-exceeded`. + +### `OPENAPI_NG_MAX_EXPANSION_RATIO` + +Maximum acceptable ratio between the re-serialised parsed YAML and the +source bytes. YAML anchors that fan out aggressively (one alias +inlined into hundreds of mapping entries) produce a re-serialised tree +orders of magnitude larger than the source; the byte-size cap above +cannot catch this because the source itself stays small. Exceeding the +ratio fails with `E_POLICY_VIOLATION` and subcode +`mapping-expansion-exceeded`. + +### `OPENAPI_NG_ALLOW_PRIVATE_HOSTS` + +URL inputs (`--input https://…`) are blocked by an SSRF guard from +resolving to private/loopback/link-local addresses by default. Set +`OPENAPI_NG_ALLOW_PRIVATE_HOSTS=1` to disable the guard for the +current process — useful when pointing the CLI at a spec served from +`localhost` or an internal mirror during development. + +## Lifecycle + +Each variable is read once per process; invalid or empty values fall +back to the default silently. diff --git a/website/src/content/docs/reference/limitations.md b/website/src/content/docs/reference/limitations.md new file mode 100644 index 0000000..46d9249 --- /dev/null +++ b/website/src/content/docs/reference/limitations.md @@ -0,0 +1,97 @@ +--- +title: Assumptions & limitations +description: The OpenAPI 3.x subset openapi-ng accepts — required fields, supported schema shapes, response/body rules, and what's out of scope. +--- + +`openapi-ng` targets a focused subset of OpenAPI 3.x. Specs outside +this subset are rejected with clear diagnostics — no silent +misgeneration. Most rejections carry a `subcode` that pins the precise +shape your spec violated; see the +[Diagnostics reference](/reference/diagnostics/) for routing. + +## Spec requirements + +- **`operationId` is required** on every operation. The generator uses + it to name service methods and request interfaces. +- **Every operation needs at least one `tag`**. The first tag + determines which service class the operation belongs to (tag `pet` → + `PetRest`). Additional tags are ignored for grouping. +- **Only `#/components/schemas/` references** are supported. External + files, URL refs, and refs to other sections (e.g. + `#/components/responses/`) are rejected. + +## Response handling + +- **Only the lowest 2xx response is used.** If your operation defines + both `200` and `201`, the `200` response is picked. Error responses + (4xx, 5xx) are completely ignored — no error type mappings are + generated. +- Default content type is `application/json`; other types route to the + matching `requestFactory.{blob,text,arrayBuffer}` variant (see the + [Angular generator](/guides/angular/#non-json-responses)). + +## Request bodies + +- **`application/json`, `multipart/form-data`, and + `application/x-www-form-urlencoded`** are supported. Other content + types — XML, custom media types, and bodies declaring multiple media + types on a single request — are rejected. + +## Parameters + +- **Path and query parameters** are fully supported and rendered at the + top of the request interface. +- **Header parameters** are accepted and emitted as a sibling `headers` + object on the request interface (alongside path/query and the body + surface), so callers pass them in the same call. +- **Cookie parameters** are accepted but **omitted from the generated + service contract** with an `E_UNSUPPORTED_SEMANTIC` / + `unsupported-parameter-location` warning — cookies are managed by the + browser, so surfacing them on the client API would be misleading. + +## Request body shape + +The generated `*Params` interface treats the body using a +smart-flatten rule keyed on how the spec author wrote the schema: + +- A body declared as `$ref: '#/components/schemas/Name'` (or any + non-object JSON shape — scalar, array, union) surfaces as a single + nested `body: Name` field, preserving the named type. +- A body declared inline as `type: object` has its properties hoisted + to top-level fields next to path/query. +- Multipart and url-encoded bodies always hoist their fields to + top-level (binary fields surface as `Blob | File`). + +The escape hatch is in the spec: name the schema to nest under `body`, +or inline a schema's contents to hoist its properties. Hoisted body +properties that duplicate a path or query parameter name are rejected +at codegen with `E_POLICY_VIOLATION` / `field-collision`. + +## Schemas + +- **String enums only.** Integer or mixed enums are rejected. +- **`not` keyword** is not supported. +- **`additionalProperties`** works only on pure object schemas — emitted + as `Record`. Cannot be combined with named `properties`, + `$ref`, or composition keywords (`allOf` / `oneOf` / `anyOf`). Boolean + `additionalProperties: true` is also rejected; it must be a schema. +- **One composition keyword per schema.** You can use `allOf`, `oneOf`, + or `anyOf`, but not two of them on the same schema. +- **`oneOf` with `discriminator: { propertyName }`** emits `A | B` on + the TypeScript surface, with each variant's discriminator property + narrowed to its wire value. When `discriminator.mapping` is set, the + mapping key becomes the literal value (e.g. `mapping: { feline: + '#/.../Cat' }` produces `kind: 'feline'` on the `Cat` variant). When + no mapping entry matches, the lowercased schema name is used as a + fallback. +- **Recursive schemas** are supported. +- **Nullable** types via OpenAPI 3.0's `nullable: true` are supported, + emitted as `T | null`. + +## Out of scope + +- Remote URLs or external file references (the `--input ` fetcher + is the one exception). +- OpenAPI 2.x (Swagger) and 3.1-specific features. +- Error response type generation. +- Custom service grouping strategies (tag-first only). diff --git a/website/src/content/docs/reference/node-api.md b/website/src/content/docs/reference/node-api.md new file mode 100644 index 0000000..227f640 --- /dev/null +++ b/website/src/content/docs/reference/node-api.md @@ -0,0 +1,203 @@ +--- +title: Node API +description: Programmatic surface — generate(), GenerateOptions, GenerateResult, and the Config file-config shape. +--- + +`openapi-ng` is a CLI-first tool, but every flag has a matching +programmatic option. Use the Node API when you want to embed generation +into a build script, a custom tool, or a test fixture. + +## `generate` + +```ts +import { generate } from '@avsystem/openapi-ng'; + +const result = await generate({ + inputPath: './spec.yaml', + outputPath: './generated', + emit: ['models', 'angular'], +}); +``` + +Returns a [`GenerateResult`](#generateresult). Throws a +[`GenerateError`](/reference/diagnostics/#generateerror) on fatal +diagnostics; pre-fatal warnings ride on `error.warnings`. + +Omit `outputPath` to keep the result fully in memory — every artifact +still shows up in `result.artifacts`. + +## `GenerateOptions` + +```ts +interface GenerateOptions { + inputPath?: string; + inputContents?: string; + displayPath?: string; + inputFormat?: InputFormat; // const enum: 'json' | 'yaml' + outputPath?: string; + emit?: Array; // const enum: 'models' | 'angular' + mappedTypes?: Array; + responseTypeMapping?: Array; + naming?: NamingConfig; +} +``` + +`InputFormat`, `EmitTarget`, and `ResponseType` (used below) are +exported both as TypeScript types and as runtime `const` objects, so +you can use them either as string literals or as named constants: + +```ts +import { EmitTarget, InputFormat, generate } from '@avsystem/openapi-ng'; + +await generate({ + inputPath: './spec.yaml', + inputFormat: InputFormat.Yaml, // or 'yaml' + emit: [EmitTarget.Models, EmitTarget.Angular], // or ['models', 'angular'] +}); +``` + +| Field | Notes | +|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `inputPath` | Path to the spec on disk. Mutually exclusive with `inputContents`; setting both or neither is rejected. | +| `inputContents` | Raw spec source. When set, `displayPath` is required and `OPENAPI_NG_MAX_INPUT_BYTES` applies to the byte length. | +| `displayPath` | Banner / diagnostic display string. Required with `inputContents`; ignored with `inputPath` (the path itself is normalised). | +| `inputFormat` | Decoder hint, only honoured with `inputContents`. Combining with `inputPath` is a shape error. | +| `outputPath` | Omit for in-memory mode. Empty string is rejected. | +| `emit` | Defaults to `['models', 'angular']`. Selecting `'angular'` alone auto-includes `'models'` with a warning. | +| `mappedTypes` | Per-schema overrides — point a generated schema at an external import. See the [Configuration guide](/guides/configuration/). | +| `responseTypeMapping` | Per-content-type override for the response decoding kind (`json` / `blob` / `text` / `arrayBuffer`). Matched case-insensitively against the spec's media types. Wires the right `requestFactory.*` variant. | +| `naming` | Customise emitted method and group names. See the [Configuration guide](/guides/configuration/). | + +### `MappedType` + +```ts +interface MappedType { + schema: string; // schema name as it appears in #/components/schemas + import: string; // package or relative path to import from + type: string; // exported type name in that module + alias?: string; // local alias if it would otherwise collide +} +``` + +### `ResponseTypeMapping` + +```ts +interface ResponseTypeMapping { + contentType: string; // matched case-insensitively + responseType: ResponseType; // const enum: 'json' | 'blob' | 'text' | 'arrayBuffer' +} +``` + +Names mirror Angular's `HttpClient.request({ responseType })` and +`httpResource.()` factories so the config vocabulary stays in JS +conventions. + +### `NamingConfig` + +```ts +interface NamingConfig { + methodName?: Naming; + group?: Naming; +} + +type Naming = string | NamingRule | Array; + +interface NamingRule { + from?: string; + parse?: RegExp; + format?: string; + case?: Case; // 'camel' | 'pascal' | 'snake' | 'kebab' | 'constant' +} +``` + +See the [Configuration guide](/guides/configuration/) for evaluation +order, fallback chains, and worked examples. `parse` requires a real +`RegExp`, so YAML/JSON configs cannot express rules that match input — +use an `openapi-ng.config.ts` / `.js` / `.mjs` / `.cjs` file in that +case. + +## `GenerateResult` + +```ts +interface GenerateResult { + summary: GenerateSummary; + diagnostics: Array; + artifacts: Array; +} + +interface GenerateSummary { + normalizedSourcePath: string; + specVersion: string; + title: string; + pathCount: number; + operationCount: number; + schemaCount: number; +} + +interface GeneratedArtifact { + path: string; // relative output path + contents: string; // emitted source, always present +} +``` + +`diagnostics` carries warnings on success. On fatal failure the +function throws a `GenerateError`; any pre-fatal warnings ride on +`error.warnings`. See [Diagnostics](/reference/diagnostics/) for the +code/subcode taxonomy and the `GenerateError` class shape. + +## `Config` — file-config shape + +The shape accepted by `.openapi-ng.{yaml,json}`, +`openapi-ng.config.{ts,mts,cts,mjs,js,cjs}`, and the `defineConfig` +helper. Keys mirror the YAML/JSON surface (`input`/`output`), **not** +the programmatic `generate({ inputPath, outputPath, ... })` surface. + +```ts +interface Config { + input?: string; + output?: string; + emit?: Array; + mappedTypes?: Array; + responseTypeMapping?: Array; + naming?: NamingConfig; +} +``` + +```ts +import { defineConfig } from '@avsystem/openapi-ng/config'; + +export default defineConfig({ + input: 'petstore.openapi.yaml', + output: './generated', + responseTypeMapping: [ + { contentType: 'application/pdf', responseType: 'blob' }, + ], +}); +``` + +`defineConfig` is an identity helper — it returns its argument +unchanged and exists purely to anchor TypeScript inference. + +## Error handling + +```ts +import { GenerateError, generate } from '@avsystem/openapi-ng'; + +try { + await generate({ inputPath: 'spec.yaml', emit: ['models', 'angular'] }); +} catch (err) { + if (err instanceof GenerateError) { + console.error(`[${err.code}] ${err.message}`); + if (err.subcode) console.error(` subcode: ${err.subcode}`); + if (err.warnings.length) console.warn(err.warnings); + } +} +``` + +`GenerateError.isGenerateError(value)` is the cross-realm-safe +predicate — use it when the error may travel across worker threads, vm +contexts, or iframes where `instanceof` matches on class identity +rather than the embedded sentinel. + +See [Diagnostics](/reference/diagnostics/) for the full code/subcode +table and remediation guide. diff --git a/website/src/content/docs/reference/runtime.md b/website/src/content/docs/reference/runtime.md new file mode 100644 index 0000000..c541850 --- /dev/null +++ b/website/src/content/docs/reference/runtime.md @@ -0,0 +1,46 @@ +--- +title: Runtime & platforms +description: Supported runtimes and platforms — Node, Bun, Deno via N-API. Browser and edge runtimes are not supported. +--- + +This page covers the runtimes that can host the **`openapi-ng` +generator** (the CLI / Node API that turns an OpenAPI spec into +TypeScript). For the runtime helpers shipped *inside* generated +output (`rest.util.ts` / `rest.model.ts`), see the [Angular generator +guide](/guides/angular/#what-rest-model-ts--rest-util-ts-ship). + +## Primary runtime + +`openapi-ng` targets Node.js as its primary runtime: the generation +engine is a Rust binary loaded via [NAPI-RS](https://napi.rs). + +Requires Node.js 18+. + +Bun and Deno are supported on the same native path because both +implement N-API and pick up the prebuilt `.node` artifact directly. + +## Platforms + +Pre-built native binaries are published for: + +- macOS (x64, ARM64) +- Linux (x64, ARM64) +- Windows (x64, ARM64) + +On any other platform (musl Linux, FreeBSD, 32-bit, etc.), `require` +of the package throws an explicit unsupported-platform error listing +the supported set — open an issue if you need an additional target. + +## Browser and edge runtimes + +`openapi-ng` does not support browser runtimes, Cloudflare Workers, +Vercel Edge, Deno Deploy, or any other host that cannot load a native +N-API binary. The package ships a `browser.js` stub that any +browser-aware bundler (Vite, Webpack, esbuild) will resolve in those +contexts; calling `generate()` from it throws a `GenerateError` with +code `E_UNSUPPORTED_RUNTIME`. + +Code generation is a build-time activity — run it from a Node script, +not from a browser bundle. See +[`E_UNSUPPORTED_RUNTIME`](/reference/diagnostics/#diagnostic-codes) +for the error shape. diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/website/wrangler.jsonc b/website/wrangler.jsonc new file mode 100644 index 0000000..4d41ba6 --- /dev/null +++ b/website/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "openapi-ng-docs", + "compatibility_date": "2025-10-01", + "assets": { + "directory": "./dist", + "not_found_handling": "404-page" + } +}