diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db55b91..9b3e043 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main, develop] pull_request: branches: [main, develop] + workflow_dispatch: workflow_call: inputs: prepare-publish: @@ -16,6 +17,23 @@ permissions: contents: read jobs: + sites: + name: Frontend checks + uses: ./.github/workflows/sites.yml + + deploy-sites: + name: Deploy websites + needs: [build, base-install, lint, types, tests, sites] + if: >- + github.repository == 'UlloaSP/mlbundle' && + github.ref == 'refs/heads/main' && + !inputs.prepare-publish && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: ./.github/workflows/deploy-sites.yml + secrets: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + build: name: Build distributions runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-sites.yml b/.github/workflows/deploy-sites.yml new file mode 100644 index 0000000..aed28bd --- /dev/null +++ b/.github/workflows/deploy-sites.yml @@ -0,0 +1,71 @@ +name: Deploy websites + +on: + workflow_call: + secrets: + CLOUDFLARE_API_TOKEN: + required: true + CLOUDFLARE_ACCOUNT_ID: + required: true + +permissions: + contents: read + +jobs: + deploy: + name: Deploy ${{ matrix.site }} + if: github.repository == 'UlloaSP/mlbundle' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - site: landing + url: https://mlbundle.eshkra.dev + title: MLbundle + - site: docs + url: https://docs.mlbundle.eshkra.dev + title: mlbundle documentation + - site: playground + url: https://playground.mlbundle.eshkra.dev + title: MLbundle Playground + environment: + name: ${{ matrix.site }} + url: ${{ matrix.url }} + concurrency: + group: mlbundle-${{ matrix.site }}-production + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version: '24.14.1' + cache: true + + - name: Install locked deployment tooling + run: vp install --frozen-lockfile + + - name: Download tested site + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.site }}-build + path: ${{ matrix.site }}/dist/ + + - name: Deploy static assets and custom domain + run: vp exec wrangler deploy --config "$SITE/wrangler.jsonc" + env: + SITE: ${{ matrix.site }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Check the deployed homepage + env: + SITE_URL: ${{ matrix.url }} + EXPECTED_TITLE: ${{ matrix.title }} + run: | + curl --fail --silent --show-error --location --retry 10 --retry-delay 15 \ + --retry-all-errors --max-time 20 "$SITE_URL/" -o "$RUNNER_TEMP/site.html" + grep -Fq "$EXPECTED_TITLE" "$RUNNER_TEMP/site.html" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 054c4a0..cf820c1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,7 +1,9 @@ name: Publish to PyPI on: - workflow_dispatch: + push: + branches: [main] + paths: [pyproject.toml] permissions: contents: read @@ -11,26 +13,45 @@ concurrency: cancel-in-progress: false jobs: + preflight: + name: Check version change + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.check.outputs.publish }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check version change and PyPI availability + id: check + env: + PREVIOUS_REF: ${{ github.event.before }} + run: python scripts/publish_check.py --previous-ref "$PREVIOUS_REF" + build: + needs: preflight + if: needs.preflight.outputs.publish == 'true' uses: ./.github/workflows/ci.yml with: prepare-publish: true publish: - needs: build + needs: [preflight, build] + if: needs.preflight.outputs.publish == 'true' && needs.build.result == 'success' runs-on: ubuntu-latest environment: name: pypi_env url: https://pypi.org/p/mlbundle - permissions: - id-token: write steps: - name: Download CI-validated distributions uses: actions/download-artifact@v4 with: name: distributions path: dist - - name: Publish to PyPI with Trusted Publishing + - name: Publish to PyPI with the project token uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ + user: __token__ + password: ${{ secrets.MLBUNDLE_API_TOKEN }} diff --git a/.github/workflows/sites.yml b/.github/workflows/sites.yml new file mode 100644 index 0000000..5abaaad --- /dev/null +++ b/.github/workflows/sites.yml @@ -0,0 +1,77 @@ +name: Check and build websites + +on: + workflow_call: + +permissions: + contents: read + +jobs: + check-build: + name: Frontend lint, types, tests and builds + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version: '24.14.1' + cache: true + + - name: Install locked dependencies + run: vp install --frozen-lockfile + + - name: Check lint, Astro formatting, types and unit tests + run: vp run check + + - name: Test the Python browser runtime and all examples + run: vp run mlbundle-playground#test:runtime + + - name: Build all three sites + run: vp run build + + - name: Validate Cloudflare deployment configuration + run: vp run check:deploy + + - name: Install Chromium + working-directory: playground + run: vp exec playwright install --with-deps chromium + + - name: Test the built playground on desktop and mobile + run: vp run mlbundle-playground#test:e2e:built + + - name: Keep browser failure evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playground-test-results + path: playground/test-results/ + if-no-files-found: ignore + retention-days: 7 + + # Only tested builds become deployable artifacts. Deployment never rebuilds them. + - name: Upload landing + uses: actions/upload-artifact@v4 + with: + name: landing-build + path: landing/dist/ + if-no-files-found: error + retention-days: 7 + + - name: Upload documentation + uses: actions/upload-artifact@v4 + with: + name: docs-build + path: docs/dist/ + if-no-files-found: error + retention-days: 7 + + - name: Upload playground + uses: actions/upload-artifact@v4 + with: + name: playground-build + path: playground/dist/ + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore index 486d3b8..105425f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,12 @@ __pycache__/ .cache/ .tox/ .nox/ +node_modules/ +.astro/ +.vite/ +.wrangler/ +playwright-report/ +test-results/ # Build output build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2bf37d3..16b35fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,8 +5,10 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + exclude: ^pnpm-lock\.yaml$ - id: check-toml - id: check-added-large-files + exclude: ^brand/mlbundle\.png$ - id: check-merge-conflict - repo: https://github.com/astral-sh/ruff-pre-commit @@ -20,3 +22,4 @@ repos: rev: v1.1.414 hooks: - id: pyright + files: ^src/ diff --git a/AGENTS.md b/AGENTS.md index 427cbfe..adee395 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,149 +1,179 @@ -# mlbundle - -mlbundle is the training-side companion to MLSuite: a Python library for putting -a trained model, a rich manifest, a locally inferred schema and model structure -in one compressed `.mlbundle` file. It owns both writing and validated reading -so that the format is defined once. - -The pre-release v0.1 library implements strict format 1 manifests, secure container -I/O, inspect/export/load, sklearn/XGBoost/CatBoost/ONNX adapters, local schema, training -dtypes, explicit samples and prediction-state verification. The CLI provides -metadata-only `inspect` and full `verify`. Format 1 is not yet frozen. -Implementation claims require code and test proof. `docs/format.md` owns the wire contract. - -## What makes mlbundle special? - -### 1. Reproducibility across the training/runtime boundary - -MLSuite turns trained models into reviewable applications; it does not train them. -Its runtime accepts only `.mlbundle` uploads. Preserve the identity and meaning -of the model, schema, features, classes and outputs across this boundary. - -### 2. Metadata is a claim, even when it looks useful - -The author of a bundle may be an attacker. Everything derived from a model, -including hyperparameters, feature/class/output counts, coefficients, feature -importances and tree structure, is a claim. Writer and reader must use the same -extraction code. The reader must recompute these claims from the loaded model -and reject mismatches. A reviewer must never see a tree that differs from the -model used for prediction. - -### 3. Training data stays on the training machine - -mlbundle depends on mlschema to call `mlschema.infer_schema` locally; mlschema -does not depend on or know mlbundle. The bundle carries the resulting fields -and report definitions. Do not include the training DataFrame. Include sample -rows only when explicitly requested: samples are real user data. - -### 4. Native formats with a narrow support boundary - -Version 0.1 supports four libraries: scikit-learn via `skops.io.dumps/loads`, -XGBoost via native in-memory UBJSON, CatBoost via CBM, and the user's ONNX -file unchanged. Do not convert models. Adapters import their library lazily; -their dependencies belong in the optional extras `mlbundle[sklearn]`, -`mlbundle[xgboost]`, `mlbundle[catboost]` and `mlbundle[onnx]`. - -The design takes ideas from MLflow's versioned manifest and native model -storage, not its code. MLflow is Apache-2.0; mlbundle is MIT. - -## A note from the maintainer - -Pablo Ulloa Santín prefers ambitious ML workflows and simple systems. Find the -owner of a rule, implement it once, and make the behavior obvious. Measure -twice, cut once, and resist scope creep. The smallest complete change must -still preserve validation, security and reproducibility. - -## A small glossary - -- **bundle** — the zip container with the `.mlbundle` extension. -- **manifest** — the versioned JSON record of identity, model metadata, - environment, member hashes and sizes, and user metadata. -- **member** — a named file inside the zip; only a closed set is permitted. -- **adapter** — mlbundle-owned code for one supported model library, including - serialization, loading and shared extraction of model-derived claims. -- **claim** — information asserted by the bundle's author; model-derived claims - require comparison with the loaded model using canonical JSON byte equality. -- **predictor** — the adapter's uniform probability/regression interface; the - native model remains available separately for runtime explanations. -- **schema** — locally inferred mlschema fields plus report definitions. -- **structure** — model internals for visualisation, tied to the actual model. -- **trusted types** — the mlbundle-owned allowlist used for skops loading. -- **inspect** — metadata-only reading that never deserializes the model. -- **runtime** — the consumer that validates a bundle before using its model. - -## The three ways to hurt yourself - -1. **Trusting the manifest.** Checksums and plausible metadata do not make an - attacker honest. Recompute model claims and reject disagreement. -2. **Opening a convenience loading path.** Pickle fallbacks, dynamic imports and - author-supplied trusted types undo the security boundary. Reject unsupported - models on the training machine instead. -3. **Defining the format twice.** Separate writer/reader extraction or schema - rules drift. Share the owning implementation and change every affected path. - -## Hit every affected layer - -- **Format.** Manifest validation, allowed members, version handling and - `docs/format.md` must agree. Reject unknown versions instead of guessing. -- **Writer.** Compute the schema locally, record the environment and fail early - for unsupported model types. Samples require explicit user intent. -- **Reader.** Validate the archive and manifest, verify hashes, load only through - closed adapters, and recompute model-derived claims. -- **Structure.** Tree arrays, ensemble tree lists, XGBoost tree dumps, linear - coefficients and feature importances must describe the loaded model. -- **Packaging.** Keep model libraries optional, imports lazy and the base - package usable without an adapter's dependencies. -- **Tests.** Cover successful round trips and every rejection path together in - the feature's test file. - -## Guardrails - -### Reader security rules +# MLbundle + +mlbundle is a Python library for packaging a trained model, a manifest, a locally +inferred schema and model structure in one compressed `.mlbundle` file. + +It owns export, metadata-only inspection and validated loading. It is the +training-side companion to MLSuite, whose runtime accepts `.mlbundle` uploads. +MLSuite does not train models. The package version is 0.2.0; format 1 is not frozen. +Implementation claims require code and test proof. + +## What must remain true + +### 1. The format is one contract + +`docs/format.md` owns the wire contract and links its normative sections. +Writer, reader, adapters, schemas and visualizations must agree with it. +Reject unknown format versions; change public contracts and versions deliberately. + +### 2. Metadata is a claim + +The author of a bundle may be an attacker. Model parameters, feature/class/output +counts, coefficients, importances and tree structure are claims, even when useful. +Writer and reader use the same extraction code. Load recomputes model-derived +claims and structure and compares canonical JSON bytes, rejecting disagreement. +A reviewer must see the model used for prediction. + +### 3. Training data stays local + +mlbundle calls `mlschema.infer_schema` locally. mlschema does not depend on or know +mlbundle. Carry its fields and report definitions, never the training DataFrame. +Include real rows only through explicit `sample_rows=N`; samples are user data. +Names, categories, learned models and structure can still reveal training information. + +### 4. Native models cross a closed boundary + +Support scikit-learn through skops, XGBoost through in-memory UBJSON, CatBoost +through CBM and existing ONNX bytes unchanged. Do not convert models. +Adapters import their libraries lazily; model dependencies belong in optional +`sklearn`, `xgboost`, `catboost` and `onnx` extras. + +### 5. Inspection and verification mean different things + +`inspect` validates container, manifest, integrity and schema/sample declarations +without importing model libraries or deserializing models. It cannot attest +model-derived claims. `load` additionally verifies native claims and structure. +The CLI exposes these as `inspect` and `verify`. Keep this distinction visible +in APIs, docs and the playground. + +## Maintainer intent + +Pablo Ulloa Santín favors ambitious ML workflows built from simple systems. +Find the owner of a rule, implement it once and make its behavior obvious. +The smallest complete change preserves validation, security and reproducibility +through every affected path. + +Remove complexity whose constraint no longer exists. Keep compatibility only +when required by the public contract or the task. Avoid speculative flags, +dead stubs and undocumented fallback behavior. + +## Vocabulary + +- **bundle**: the ZIP container with the `.mlbundle` extension +- **manifest**: versioned identity, model metadata, environment, integrity records and user metadata +- **member**: a named file from the container's closed allowed set +- **adapter**: owned recognition, native I/O, shared extraction and prediction for one library +- **claim**: information asserted by the author; model-derived claims require recomputation +- **schema**: locally inferred mlschema fields and report definitions +- **structure**: model internals for visualization, bound to the loaded model +- **predictor**: uniform probability/regression interface, separate from the native model +- **trusted types**: mlbundle-owned allowlists for skops loading +- **runtime**: the consumer that validates a bundle before using its model + +## Invariants + +- Source, test and documentation files stay at or below 300 lines. Split by responsibility. +- Pydantic v2 is the sole core runtime dependency. New dependencies need a concrete reason; model libraries remain extras. +- Keep `uv_build` as the Python build backend. +- Avoid Python 3.14-only syntax so widening support can remain a metadata change. +- Preserve model, feature, class, output and schema identity across export and load. +- Safety ceilings may only be lowered by callers. +- Tests for a feature keep successful round trips and rejection cases together in one file. +- Verification claims describe commands actually run and their real results. +- `C:\software\mlsuite`, `C:\software\mlschema`, `C:\software\mlform` and `C:\software\eshkra` are read-only references. Never modify, commit or run write commands in them. +- sklearn-to-ONNX conversion, per-model environments, bundle signing and additional model libraries (including LightGBM and PyTorch) are outside scope. + +## Reader security + +These rules apply to every loading and inspection path, including examples and UI: - Never use pickle, joblib or cloudpickle, even as an option or fallback. -- Never import or instantiate from strings in the manifest. The class to - instantiate must come from a closed list inside mlbundle. -- The skops trusted lists live in mlbundle; never read them from a bundle or add XGBoost/CatBoost types. Tree, optimizers, scalar dtypes and closed functions each admit only their owning loaders; see `docs/format-state.md`. -- Export must fail on the user's machine when the model contains custom types, - listing unsupported types from `skops.io.get_untrusted_types`. Do not defer - discovery to upload time or offer to trust those types. -- Read the zip in memory by allowed member names. Never extract it to disk. - Reject unknown or duplicate members, paths with separators or `..`, symlinks - and encrypted entries. Enforce limits on member count, compressed size, - uncompressed size and compression ratio before allocating or loading content. -- Verify each member's SHA-256 against the manifest. Hashes detect corruption, - not deliberate tampering; signing is out of scope. -- Validate the manifest strictly and reject an unknown format version. -- Provide a metadata-only `inspect` read that does not deserialize the model. -- Recompute all model-derived claims with the writer's extraction code and - reject a bundle when they differ from the loaded model. - -### Repository rules - -- No source, test or documentation file may exceed 300 lines. Split by responsibility before the limit. -- Add no runtime dependency without a concrete reason. The sole core dependency - is Pydantic v2 for strict manifest validation; model libraries belong in extras. -- Keep a feature's tests in one file covering its success case and every error - case. Test observable behavior and supported contracts. -- Avoid Python 3.14-only syntax; widening the Python range should be a metadata - change rather than a source rewrite. -- Preserve public contracts and change format versions deliberately. Leave no - dead stubs, speculative flags or undocumented fallback behavior. -- Use Conventional Commits (`chore:`, `docs:`, `ci:`, `feat:`, `fix:`, `test:`). -- Never add `Co-Authored-By`, AI attribution or "Generated with" lines to commits - or PR text. -- `C:\software\mlsuite` and `C:\software\mlschema` are read-only references. - Never modify, commit or run write commands inside either checkout. -- Publish only via manual `publish.yml` from `main`, through `pypi_env` with owner approval, on the owner's explicit request for that specific version. - Agents never run or approve it on their own initiative or add publish paths/tokens/secrets. No GitHub releases/version tags; "tags" means GitHub repository topics. -- sklearn-to-ONNX conversion, per-model environments, bundle signing and additional - libraries (including LightGBM and PyTorch) are outside the project scope. -- Never claim a verification that was not run; report its real output or blocker. - -## Development - -Work from the repository root using `uv` on Python 3.14. The optional `schema` -extra admits mlschema >=0.2.1,<0.3 on 3.14 only. Keep `uv_build` as the build backend. +- Instantiate only from mlbundle's closed class lists. Never import or instantiate from manifest strings. +- Own skops trusted lists in mlbundle; never accept them from a bundle or add XGBoost/CatBoost types. Trees, optimizers, scalar dtypes and closed functions admit only their owning loaders. +- Reject custom types at export on the training machine. List unsupported types from `skops.io.get_untrusted_types`; never offer to trust them or defer rejection to upload. +- Read ZIP members in memory by allowed names. Never extract the archive to disk. +- Reject unknown/duplicate members, separators or `..` in names, symlinks and encrypted entries. +- Enforce member count, compressed size, uncompressed size and compression ratio before allocation or loading. +- Validate the manifest strictly and verify every non-manifest member's SHA-256 and size. Hashes detect corruption, not authenticity or deliberate tampering. +- Recompute model-derived claims and structure using the writer's extraction code before returning a loaded model. +- Keep errors bounded. Record-input errors name escaped keys without echoing values. + +Read `SECURITY.md` before changing trust boundaries. Read `docs/format-container.md` +before changing archive parsing or limits, and `docs/format-state.md` before +changing skops trust, fitted state or supported types. + +## How MLbundle fits together + +1. A closed adapter recognizes the model and prepares it without mutating the user's object. +2. Shared extraction derives model claims and structure from native state. +3. Native serialization and reload prove those claims before export publishes the bundle. +4. Local mlschema inference supplies fields/reports; shared code resolves features, targets and environment. +5. The writer computes integrity records, validates through the reader and writes a deterministic ZIP. +6. Inspect validates bounded container/JSON data and declarations without adapter resolution. +7. Load uses the closed adapter and compares recomputed claims and structure. +8. The predictor and record APIs expose validated inputs and output-major report results. + +The allowed members are manifest, required schema, one native member (or a declared +skops + UBJSON/CBM pair), extractable structure and explicit samples: at most six. +Publication uses atomic hard links or replacement and requires explicit overwrite. +The exclusive-create fallback removes partial destinations on failure but exposes +the file during copying. Preserve that behavior and document its limits honestly. + +## Ownership and contract references + +Use the entry points and linked contracts to find the owning implementation: + +- **Public I/O:** `model_io.py`, `inspection.py`, `results.py` and `cli.py`; `__init__.py` owns public exports. +- **Container:** `container.py`, `writing.py`, `_container_rules.py`, `_zip_directory.py`, `_zip_members.py`, `limits.py` and `integrity.py`. +- **Declarations:** `manifest.py`, `canonical.py`, `declarations.py`, `environment.py`, `training.py`, `schema.py` and `samples.py`. +- **Adapters:** `adapters.py` and `registry.py` define closed lazy resolution; `_sklearn/`, `_xgboost/`, `_catboost/`, `_onnx/` and `_composite/` own native behavior. +- **Claims and predictions:** `claims.py`, `records.py`, `_prediction_input.py`, `_prediction_decisions.py` and `_display.py`. +- **Websites:** `landing/`, `docs/` and `playground/`; `brand/` owns the shared identity sourced from eshkra. + +Paths above are relative to `src/mlbundle/` unless they name a root directory. +Use package configuration and source for detailed inventories and command options. + +When changing schema, mappings or samples, read `docs/format-schema.md`: + +- Every export needs the schema extra, including default inference from one float-1.0 row. +- Schema v2 records source and separator (default `__`). Real frames exclude every declared target and record training dtypes; default dtypes are null. +- Native model names map by name; other origins map by position, with generated names starting at `feature_0`. +- Unique target names map by output position. Reports use string labels; manifest labels retain their types. +- MLschema owns series columns and explicit conversion to positional mappings. Export/inspect/load share declaration validation without reader-side mlschema. + +When changing native I/O or extraction, read `docs/format-adapters.md` and the +adapter-specific contracts it links: + +- `prepare_export` accepts strict `ExportDeclarations` and leaves the original model unchanged. Missing native names are null; shared code owns declared/generated names and environment. +- Null native classifier labels permit count-checked declarations. Sklearn/CatBoost own `classes_` and reject `class_labels=`; XGBoost binds supplied output-major labels to a native copy and otherwise uses encoded indices. +- XGBoost supports exact single-target tree wrappers with closed objectives. CatBoost supports exact numeric single-target symmetric models with complete native state/structure and in-memory CBM loading. +- Read `docs/format-pipeline.md` for composites. Exact outer sklearn Pipelines ending in XGBoost/CatBoost use checked skops/native components; other native placements remain rejected. Composite trees address transformed inputs; all-sklearn Pipeline structure stays null. +- Read `docs/format-onnx.md` before changing ONNX. Preserve path/bytes unchanged, serialize ModelProto once and validate once per export/load with bounded scalar scans before CPU sessions. Reject external tensors, custom domains/functions and runtime/training configuration. Verify signatures, metadata/environment claims and native labels; structure stays null. +- Native errors become bounded `MLBundleError`s. Version hints diagnose failed manifests/skops loads without adding compatibility gates. + +When changing record prediction, preserve shared schema-key lookup, bounded +numeric-string/bool conversion and schema-permitted None-to-NaN handling. +Never iterate extra keys. Frozen output-major results use the native class +decision's report label, never one inferred from probabilities. + +## Check every affected path + +Apply the entries relevant to the change: + +- **Format:** versions, manifest validation, allowed members and normative docs agree. +- **Writer:** unsupported models fail early; local inference, environment and explicit samples remain correct. +- **Reader:** archive bounds, hashes, closed loading and recomputed claims remain enforced. +- **Structure:** visualized trees, coefficients and importances describe the loaded model. +- **Schema:** fields, reports, names, dtypes, mappings and samples agree across export/inspect/load. +- **Packaging:** base installs work without adapter libraries; imports remain lazy. +- **Websites:** examples use actual APIs, inspection claims are accurate, controls work by keyboard and layouts remain usable on mobile. +- **Tests:** success and affected rejection paths exercise observable contracts. + +A change is incomplete when it works only through the path used by its first test. + +## Development and testing + +Work from the repository root with `uv` on Python 3.14: ```shell uv python install 3.14 @@ -151,14 +181,16 @@ uv sync --locked --all-extras uv run pre-commit install ``` -After changing dependency metadata, use `uv sync` to regenerate `uv.lock` and -commit the lockfile. CI and manual `publish.yml` use locked dev/all extras on 3.14. -Default dev installs `mlbundle[all]`; base users can omit it. Publishing reuses CI. -Ruff handles lint and formatting; Pyright uses the family's basic mode. +The schema extra admits mlschema `>=0.3.0,<0.4` on Python 3.14 only. Default dev +installs include `mlbundle[all]`; base users can omit them. Dependency metadata +changes require `uv sync` to regenerate `uv.lock` and inclusion of the updated +lockfile in the change. CI and publishing use locked dev/all extras on 3.14. -## Verifying +Start with the smallest proof of observable behavior. Cover success and affected +errors together; use regression tests for bugs and integration tests for shared +boundaries. Use browser checks when rendering, focus or keyboard behavior matters. -Run these commands from the repository root: +Before handing off Python source or package changes, run: ```shell uv run ruff check . @@ -168,133 +200,49 @@ uv run pytest uv build ``` -The Ruff commands also work without the final `.` because the default is the -current directory: `uv run ruff check` and `uv run ruff format --check`. -`uv run pytest` runs `tests/`, measures branch coverage and enforces the family's -80% minimum, writing terminal and XML reports. Tests use the installed package; -its version comes from package metadata. CI checks lint/types and the built wheel -on Ubuntu/Windows; manual `publish.yml` requires that CI. Build a wheel and sdist. +Ruff owns lint/formatting; Pyright uses basic mode. Tests use the installed package, +obtain its version from metadata and enforce 80% branch-inclusive coverage with +terminal/XML reports. Build both wheel and sdist. CI tests the built wheel on +Ubuntu/Windows, including base installs. -Start with the smallest proof of a behavior change, then broaden when needed. -If a check cannot run, report the exact command and blocker. Never claim a test, -environment or behavior was verified when it was not. +Use Vite+ (`vp`) for all three frontend projects: `vp install`, `vp run check` +and `vp run build` at the root, or the site's `package.json` scripts via `vp run`. +Astro commands go through `vp exec`; the playground uses Vite+ directly. +A docs-only edit needs relevant examples/links/build checks, not every +Python gate. If any check cannot run, report its exact command and blocker. ## Documentation -- `README.md` owns purpose, honest implementation status and development setup. -- `SECURITY.md` owns the threat model, guarantees, limits and private reporting. -- `docs/format.md` owns the version 1 wire specification and verification rules. -- Rewrite stale guidance instead of appending competing accounts. +Keep README focused on identity, installation, primary usage, supported surfaces +and links to detailed guides. `SECURITY.md` owns the threat model, guarantees, +limits and private reporting. `docs/format.md` owns the wire specification; +web docs must reuse the normative files instead of maintaining parallel copies. + +Document user-visible contracts and durable cross-module decisions. Rewrite +stale guidance rather than appending a competing account. Keep all agent policy +in this root file; do not add nested AGENTS.md files. -Keep agent policy in this root file. Do not add nested `AGENTS.md` files. +## Git, publishing and work artifacts -## Plans and work artifacts +Preserve unrelated working-tree changes. Do not commit, push, create branches +or open pull requests unless explicitly requested. Use Conventional Commits +(`chore:`, `docs:`, `ci:`, `feat:`, `fix:`, `test:`). +Never add `Co-Authored-By`, AI attribution or "Generated with" lines to commits or PRs. + +PyPI publication is automatic when `project.version` changes in a push to `main`. +`publish.yml` must run the full CI workflow first and publish only that new version +through the `pypi_env` environment, whose branch policy is limited to `main` and +whose `MLBUNDLE_API_TOKEN` secret is project-scoped. Never read or expose secret +values, change versions just to trigger publication, or invoke uploads manually. +No GitHub releases or version tags; "tags" means repository topics. Do not create plan, notes, TODO, lessons, scratch or tech-debt files in the repo. -The conversation, issues, code, tests and final diff are the work record. - -## How it works - -The writer accepts member bytes and manifest parts, computes integrity records, -validates through the reader and produces a deterministic ZIP. Publication uses -atomic hard links or replacement, requiring explicit overwrite. Exclusive-create -fallback removes partial destinations on failure but exposes the file while copying. -Sklearn/XGBoost/CatBoost exports verify shared claims/structure after a native -skops/UBJSON/CBM round trip before publication. Safety ceilings may only be lowered. - -Every export requires the schema extra. mlschema infers from `training_data=` or -one float-1.0 row, recording schema v2 source/separator (default `__`). Real frames -exclude every declared target and record training dtypes; default dtypes are null. -Only explicit `sample_rows=N` includes real rows. Model-origin mappings use names; -others use positions; generated names start at `feature_0`. Unique `target_names` -map by output position. Reports use string labels; manifest labels retain types. -Series pairs are adapted once to MLForm columns until mlschema emits that shape. -Export/inspect/load share schema/report/sample validation without reader-side mlschema. - -XGBoost supports exact single-target tree wrappers with closed objectives. Supplied -output-major labels bind to a native copy via an owned booster attribute and are -verified on load; omitted labels use encoded indices. Sklearn/CatBoost own classes_ -and reject `class_labels=`. CatBoost supports exact numeric single-target symmetric -models, complete native state/structure and in-memory CBM loading. Exact outer -sklearn Pipelines ending in XGBoost/CatBoost use checked skops/native components; -other native placements remain rejected. - -ONNX preserves path/bytes unchanged (ModelProto serialized once), validates once -per export/load with bounded scalar scans, and rejects external tensors, custom -domains/functions and runtime/training configuration before a CPU session. It -verifies signatures, metadata/environment claims and native labels; absent labels -use count-checked declarations. OnnxModel retains bytes/session; structure is null. -Read the normative ONNX section linked from `docs/format.md` for layouts and bounds. - -The closed member set is manifest, required schema, one native member (or declared -skops + UBJSON/CBM pair), required extractable structure, and explicit samples: -at most six members. The manifest records identity/time/writer, model metadata, -ordered features/origin/dtypes, output-major targets/classes, training environment, -member hashes/sizes and user metadata. Model-exposed dtypes are separately verified. -Every declaration is untrusted; hashes do not establish authenticity. - -Inspect validates archive/JSON bounds, manifest, integrity and schema/samples -without model imports/deserialization; it cannot attest model-derived claims. -Load uses closed adapters and shared extraction to recompute claims and structure. -`row_from_record`/`predict_record` share schema-key lookups, bounded numeric-string -and bool conversion, optional None-to-NaN handling and escaped key errors without -values. Extra keys are never iterated. Predictions are frozen output-major report -results, including the native class decision's report label (never inferred from -probabilities). Safe version hints diagnose failed manifests/skops loads without new gates. - -Adapters own recognition, preparation, serialization/loading, claims, structure -and prediction; extraction uses only native state and is shared by export/load. -`prepare_export` receives strict ExportDeclarations and leaves the user's object -unchanged. Missing native names are null; shared code owns declared/generated names -and environment. Null native classifier labels permit count-checked declarations, -including ONNX without byte changes. Native errors become bounded MLBundleErrors. -Keep native behavior in adapters; follow `adapters.py`. - -## Where code lives - -- `src/mlbundle/__init__.py` — public inspect/export/load, contract models, enums, errors and package version. -- `src/mlbundle/cli.py` — argparse inspect/verify console entry with safe bounded display. -- `src/mlbundle/_base.py` and `errors.py` — strict frozen model configuration, string bounds and bounded errors. -- `src/mlbundle/contract.py` — closed identifiers and native member/format pairing. -- `src/mlbundle/limits.py` — shared archive ceilings and specific member caps. -- `src/mlbundle/canonical.py` and `_canonical_work.py` — deterministic JSON and bounded decoding/expansion. -- `src/mlbundle/model_io.py`, `_native_io.py`, `_export_manifest.py` — public I/O, native round trips and manifest construction. -- `src/mlbundle/_composite/`, `_pipeline_contract.py` — strict recipe, separate I/O, closed reconstruction/claims/prediction and input/inner boundaries. Read `docs/format-pipeline.md` when changing composites. -- `src/mlbundle/training.py` — lazy local/default inference and feature origin/dtype resolution. -- `src/mlbundle/schema.py` and `_schema_fields.py` — closed schema/report declarations and reader cross-checks without mlschema. -- `src/mlbundle/_schema_inference.py` — temporary mlschema-to-MLForm series adaptation. -- `src/mlbundle/samples.py` — explicit feature-row selection and bounded cell conversion. -- `src/mlbundle/_sklearn/` — owned trust/skops/state/parameters/structure, transformer route and closed fitted-width/name proofs. Composite commitments/native structure bind the rebuilt Pipeline; all-sklearn structure stays null. -- `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. -- `src/mlbundle/_catboost/` — closed numeric symmetric wrappers/losses, CBM preflight/in-memory loading, complete native state/structure and validated prediction. -- `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. -- `src/mlbundle/declarations.py` — class-label resolution and shared output-major target-name validation. -- `src/mlbundle/claims.py` — shared extracted claims and exact claim/structure comparison. -- `src/mlbundle/manifest.py` — manifest sections, cross-field validation and JSON boundaries. -- `src/mlbundle/integrity.py` — member SHA-256/size records and verification. -- `src/mlbundle/environment.py` — training declarations and distribution metadata capture. -- `src/mlbundle/adapters.py`, `_prediction_input.py`, `_prediction_decisions.py` — adapter/predictor protocols, input checks and bounded native class mapping. -- `src/mlbundle/registry.py` — closed lazy resolution of `_sklearn`, `_xgboost`, `_catboost` and `_onnx` adapters. -- `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. -- `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. -- `src/mlbundle/_zip_members.py` — local headers/descriptors and bounded decompression with CRC. -- `src/mlbundle/container.py` — source bounds, validated in-memory reading and integrity checks. -- `src/mlbundle/writing.py` — integrity ownership, deterministic ZIP and atomic publication. -- `src/mlbundle/inspection.py`, `results.py` — metadata-only inspection and result records without adapter resolution. -- `src/mlbundle/records.py`, `_display.py` — record input/output and shared safe display. -- `src/mlbundle/_versions.py`, `_load_diagnostics.py` — bounded post-failure version hints. -- `docs/format.md` — version 1 wire specification; linked container, adapter/structure, ONNX and schema/sample sections are normative. -- `tests/` — one file per feature, with success and error cases together. -- `scripts/package_smoke.py`, `publish_check.py` — artifact checks and publish preflight. -- `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. -- `.github/workflows/ci.yml`, `publish.yml` — shared CI and owner-approved publishing. -- `.pre-commit-config.yaml` — Ruff, Pyright and basic file hygiene. -- `README.md`, `SECURITY.md` and this file — project and contributor context. - -## Taste - -- One format definition beats synchronized copies. -- A closed adapter boundary beats dynamic loading. -- Early export errors beat surprises after upload. -- Honest metadata beats impressive but unverifiable visualisations. -- Simplicity never excuses weak validation or data safety. +Keep generated coverage and browser evidence out of changes. The conversation, +issues, source, tests and final diff are the work record. + +## Done + +Work is complete when every applicable path works, the owning layer contains +the rule, contracts remain intentional, obsolete behavior is gone, useful +regression coverage exists, relevant checks have run and documentation agrees. +Simplicity never excuses weaker validation or data safety. diff --git a/README.md b/README.md index cca674a..05065a7 100644 --- a/README.md +++ b/README.md @@ -1,297 +1,226 @@ -# mlbundle - -Package a trained model, manifest, locally inferred schema and structure in one `.mlbundle` file. -Export on the training machine and validate/load in the consuming application with the same Python library. - -mlbundle replaces three gaps in separate model/DataFrame uploads: +<p align="center"> + <img src="https://raw.githubusercontent.com/UlloaSP/mlbundle/main/brand/mlbundle.png" alt="" height="112" /> +</p> + +<h1 align="center"> + <picture> + <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/UlloaSP/mlbundle/main/brand/wordmark-dark.svg" /> + <img src="https://raw.githubusercontent.com/UlloaSP/mlbundle/main/brand/wordmark-light.svg" alt="MLbundle" height="52" /> + </picture> +</h1> + +<p align="center">Your trained model, schema and verified structure. One file.</p> + +<p align="center"> + <a href="./landing/"><img src="https://img.shields.io/badge/website-303039?style=flat&logo=astro&logoColor=ff6a1a" alt="Website source" /></a> + <a href="./docs/"><img src="https://img.shields.io/badge/docs-303039?style=flat&logo=readthedocs&logoColor=ff6a1a" alt="Documentation" /></a> + <a href="./playground/"><img src="https://img.shields.io/badge/playground-303039?style=flat&logo=stackblitz&logoColor=ff6a1a" alt="Playground" /></a> + <a href="https://pypi.org/project/mlbundle/"><img src="https://img.shields.io/badge/version-0.2.0-b73d00?style=flat&logo=pypi&logoColor=white&labelColor=303039" alt="Version 0.2.0" /></a> + <a href="https://github.com/UlloaSP/mlbundle"><img src="https://img.shields.io/badge/source-303039?style=flat&logo=github&logoColor=white" alt="Source on GitHub" /></a> + <a href="https://github.com/UlloaSP/mlbundle/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/UlloaSP/mlbundle/ci.yml?branch=main&style=flat&label=CI&labelColor=303039" alt="CI status" /></a> + <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-b73d00?style=flat&labelColor=303039" alt="MIT license" /></a> + <a href="https://eshkra.dev"><img src="https://img.shields.io/badge/part_of-eshkra-b73d00?style=flat&labelColor=303039" alt="Part of eshkra" /></a> +</p> + +MLbundle packages a trained model with its manifest, locally inferred input schema +and extractable structure in one compressed `.mlbundle` file. The same library +writes the bundle and validates it when loaded, preserving feature order, class +labels and output meaning across the training/runtime boundary. + +Use it to hand a model to [MLSuite](https://github.com/UlloaSP/mlsuite), inspect +its metadata or build your own consumer around the validated native model. + +## Why bundle a model? + +A model file alone leaves the next application to recover its inputs, labels, +environment and supporting metadata. Separate descriptions can drift from the +model they are supposed to describe. + +MLbundle keeps them together and checks model-derived claims against the loaded +model. Schema inference runs locally through [MLschema](https://github.com/UlloaSP/mlschema); +real training rows are included only when explicitly requested. Native formats +preserve the supported model without conversion. + +## Installation + +MLbundle **0.2.0** requires Python **3.14** for export with the schema extra. +The installation command for this release is: + +```bash +python -m pip install "mlbundle[sklearn,schema]==0.2.0" +``` -- **Pickle code execution:** closed adapters use skops, native XGBoost UBJSON, - CatBoost CBM or an existing ONNX file, without pickle/joblib/cloudpickle. -- **Missing training versions:** the manifest records Python, OS, architecture - and installed package versions alongside model and feature metadata. -- **Training data leaving the machine:** schema inference runs locally; - training rows are included only when explicitly requested as samples. +To use a local checkout, run `python -m pip install -e ".[sklearn,schema]"` +from its root. The base library requires Python >=3.14; schema inference uses +mlschema `>=0.3.0,<0.4` on Python 3.14 only. -**Pre-alpha v0.1.0a3, not yet published.** Version 0.1.0a2 is on PyPI. Format version 1 is -not frozen; bundles may need re-export. The security design has not had an -independent review: do not accept bundles from untrusted parties. -Requires **Python >=3.14**; tested on Windows/Linux. Schema requires Python 3.14. +Choose `xgboost`, `catboost` or `onnx` instead of `sklearn`, or install `[all]`. +Export always needs `schema`. Metadata-only inspection needs only the base +package; load and CLI verification need the matching model extra. -## Install and try scikit-learn +## Quick start -Install the pre-release with -`python -m pip install "mlbundle[sklearn,schema]==0.1.0a3"` once published. -Replace `sklearn` with `xgboost`, `catboost` or `onnx`, or use `[all]` for all dependencies. -Export needs the `schema` extra; reading does not. Omit the brackets for metadata-only inspection; load/verify needs the matching model extra. +Train a model, export its schema without including training rows, then inspect +and load the bundle: ```python +import pandas as pd from sklearn.tree import DecisionTreeClassifier -from mlbundle import export, inspect, load - -rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] -model = DecisionTreeClassifier(random_state=7).fit(rows, ["cold", "warm"] * 2) -export(model, "trained.mlbundle", feature_names=["temperature", "humidity"]) -bundle = load("trained.mlbundle") -assert isinstance(bundle.native_model, DecisionTreeClassifier) -assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() -assert bundle.class_labels == [["cold", "warm"]] -print(bundle.predict_proba([[0.0, 1.0]])) # [[1.0, 0.0]] -assert inspect("trained.mlbundle").manifest.model.library == "sklearn" -``` - -`export` returns metadata and requires `overwrite=True` for existing destinations. -`load` accepts paths, bytes or streams. Classifiers use `bundle.predict_proba`; -regressors use `bundle.predict`. Results are plain lists: output-major classifier -labels/probabilities, row-major regression values. `native_model` supports native -inference/explanations; mutating it invalidates the metadata verification. - -Native names win; otherwise origin is `declared`, `dataframe`, or `generated` (`feature_0` through `feature_{N-1}`). -For models without native names, pass a 2-D NumPy array or nested numeric rows in manifest feature order; declared names do not add name-based prediction. -`target_names=` and `metadata=` remain author declarations. -## Runtime records - -Load with the matching model extra, then use the bundle's own record mapping: +from mlbundle import export, inspect, load -```python -from mlbundle import load +training = pd.DataFrame( + { + "temperature": [0.0, 1.0, 0.1, 0.9], + "humidity": [1.0, 0.0, 0.9, 0.1], + "target": ["cold", "warm", "cold", "warm"], + } +) +model = DecisionTreeClassifier(random_state=7).fit( + training.drop(columns="target"), training["target"] +) -bundle = load("trained.mlbundle") -record = {"0": "0.0", "1": "true"} -print(bundle.predict_record(record)[0].probabilities) # (1.0, 0.0) -assert bundle.row_from_record(record) == [0.0, 1.0] # Also usable for explanations. -``` +export( + model, + "trained.mlbundle", + training_data=training, + target_names=["target"], +) -Model-origin keys are native feature names. Other origins use zero-based positions -as integers or canonical decimal strings; supplying both forms is an error. -Every feature is required as a key. Extra keys are ignored without iterating the -record, so work depends on feature count rather than record size. -Values accept finite Python numbers, booleans and decimal or true/false strings. -Strings strip ASCII whitespace and are limited to 64 characters; decimal forms -include `.5`, `1.` and exponents. `None` becomes NaN only when the schema field -permits missing values, using the group's requirement for one-hot options. -`PredictionInputError` names invalid keys without echoing values. Array/DataFrame -prediction retains its existing rules. Results are frozen `OutputPrediction` -records in output order, with classifier labels/probabilities or a regression value. -`predicted_label` is the report string for the native model's own class decision, -which may differ from the largest probability. It is `None` for regressors and -ONNX classifiers without a graph label output; mlbundle never substitutes argmax. -Name-selecting sklearn pipelines use a DataFrame and require pandas (included in -`schema`); other models use a numeric row, preserving native categorical handling. -`EnvironmentMismatchError` diagnoses a failed skops load with differing sklearn -versions. `InvalidManifestError` explains safe, different writer versions on a -failed manifest check. Neither diagnostic adds a compatibility gate. - -## XGBoost - -Install the `xgboost` extra. Train on encoded indices and supply the original -labels in probability-column order, nested even for one output: +metadata = inspect("trained.mlbundle") # No model deserialization. +print(metadata.manifest.features.names) # ["temperature", "humidity"] -```python -from xgboost import XGBClassifier -from mlbundle import export, load - -rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] -model = XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit(rows, [0, 1, 0, 1]) -export(model, "xgboost.mlbundle", class_labels=[["cold", "warm"]]) -bundle = load("xgboost.mlbundle") -assert isinstance(bundle.native_model, XGBClassifier) -assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() -assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() -assert bundle.class_labels == [["cold", "warm"]] +bundle = load("trained.mlbundle") # Recomputes native claims and structure. +result = bundle.predict_record({"temperature": 0.0, "humidity": 1.0})[0] +print(result.predicted_label) # cold +print(result.probabilities) # (1.0, 0.0) ``` -Labels/settings bind to a native **copy**. Load verifies consistency, not history. -Omitted labels use encoded indices/an existing binding; native predict stays encoded. -Standalone sklearn, CatBoost and regressors reject `class_labels=`; composites -delegate labels to the final native adapter. +The target column is excluded from the input schema. Add `sample_rows=N` only +when you intend to include the first N real feature rows. Without +`training_data=`, export infers a default schema from synthetic values. -Only recoverable native configuration is recorded; learning rate, max depth, -seed and thread count do not survive native XGBoost I/O and are not reconstructed. +Existing destinations require `overwrite=True`. Native feature names take +precedence; otherwise mappings use zero-based positions. `load` and `inspect` +accept paths, bytes or streams. The native model remains available as +`bundle.native_model`; mutating it invalidates the verified metadata. -## CatBoost +## Choose your operation -Install the `catboost` extra (>=1.2.10; includes matplotlib/plotly). Numeric, -single-target symmetric classifiers/regressors use native CBM and native labels: +| Operation | Purpose | +| --- | --- | +| `export(model, destination, ...)` | Validate a native round trip, infer the schema locally and write a bundle. | +| `inspect(source)` | Validate the container, manifest, hashes and declarations without loading a model. | +| `load(source)` | Load through a closed adapter and verify model-derived claims and structure. | +| `bundle.predict_proba(data)` | Classifier probabilities as plain lists. | +| `bundle.predict(data)` | Regressor predictions as plain lists. | +| `bundle.predict_record(record)` | Map one record through the schema and return frozen output-major results. | +| `bundle.row_from_record(record)` | Validate and order record values for native inference or explanations. | -```python -from catboost import CatBoostClassifier -from mlbundle import export, load - -rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] -model = CatBoostClassifier( - iterations=3, depth=2, verbose=False, allow_writing_files=False -).fit(rows, ["cold", "warm"] * 2) -export(model, "catboost.mlbundle") -bundle = load("catboost.mlbundle") -assert bundle.class_labels == [["cold", "warm"]] -assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() -``` - -`class_labels=`, categorical/text/embedding features, -Depthwise/Lossguide, custom Python objectives/metrics and other losses are rejected. -Classifier losses: Logloss, CrossEntropy, MultiClass. Regressors: RMSE, MAE, -Quantile, Huber, Poisson, MAPE. Complete trees/borders/NaN handling/importances -are verified on load. [The CatBoost contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-catboost.md) details -wrapper recovery, native-parser limits and the private in-memory serializer. +The CLI exposes metadata inspection and full verification: -## Composite pipelines - -Install `[sklearn,xgboost]` or `[sklearn,catboost]`. Export the fitted Pipeline -as it is; transformers use skops and the final estimator uses its native adapter: - -```python -from sklearn.pipeline import make_pipeline -from sklearn.preprocessing import StandardScaler -from xgboost import XGBClassifier -from mlbundle import export, load - -rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] -model = make_pipeline(StandardScaler(), XGBClassifier(n_estimators=3, n_jobs=1)) -model.fit(rows, [0, 1, 0, 1]) -export(model, "pipeline.mlbundle", class_labels=[["cold", "warm"]]) -bundle = load("pipeline.mlbundle") -assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() -assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() -assert bundle.class_labels == [["cold", "warm"]] +```shell +mlbundle inspect trained.mlbundle +mlbundle inspect trained.mlbundle --json +mlbundle verify trained.mlbundle ``` -Load rebuilds a real Pipeline after verifying both parts. Bundle probability labels -use XGBoost's bound originals; native classes/predict stay encoded. CatBoost keeps -native labels. Prefixes include scalers, PCA, SimpleImputer (indicators/empty columns), -KBinsDiscretizer, PolynomialFeatures, univariate selectors, OneHotEncoder/OrdinalEncoder, -identity/closed NumPy FunctionTransformer, nested native-free Pipeline and ColumnTransformer. -Each needs a fitted, data-free width proof. Unknown widths, FeatureUnion/KNNImputer, -slice/callable/scalar selectors, caching, metadata routing and other native placements -remain rejected. Step names/outer options are declarations; trees address transformed -features, without original-feature attribution. Schema/dtypes/samples describe original -inputs; six members permit schema plus explicit samples. See [the pipeline contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-pipeline.md). - -## Existing ONNX files - -Install the `onnx` extra. This example assumes your existing `trained.onnx` is -a self-contained tabular classifier with two numeric features and two probability -columns. Labels on a label-free graph are count-checked declarations: - -```python -from pathlib import Path -from mlbundle import export, load - -original = Path("trained.onnx").read_bytes() -export("trained.onnx", "onnx.mlbundle", class_labels=[["cold", "warm"]]) -bundle = load("onnx.mlbundle") -assert bundle.native_model.data == original -assert bundle.native_model.session.get_providers() == ["CPUExecutionProvider"] -assert bundle.class_labels == [["cold", "warm"]] -print(bundle.predict_proba([[0.0, 0.0]])) +Inspection does not prove that metadata describes the model. Full verification +loads the model and compares its claims. Neither operation establishes authorship +or training history. + +## Supported models + +| Library | Storage | Support boundary | +| --- | --- | --- | +| scikit-learn | skops | Closed fitted classifier/regressor families and supported transformers. | +| XGBoost | Native UBJSON | Exact single-target tree wrappers and closed objectives. | +| CatBoost | Native CBM | Exact numeric, single-target symmetric classifiers/regressors. | +| ONNX | Original ONNX bytes | Self-contained tabular models with supported signatures; no conversion. | +| Composite pipelines | skops + UBJSON or CBM | Supported sklearn preprocessing with an XGBoost/CatBoost final estimator. | + +Custom types fail at export. Sklearn and CatBoost own their class labels; +XGBoost can bind declared labels to a native copy. ONNX has no visualization +structure, and all-sklearn Pipeline structure is null. The +[adapter contracts](./docs/format-adapters.md) define the exact support limits. + +## Some notes + +Package versions and wire-format versions are independent. Format 1 is not frozen; +incompatible schema shapes require re-export. The security design has not had an +independent review; do not accept bundles from untrusted parties. + +The reader bounds ZIP/JSON data, verifies integrity and uses closed native loaders. +Hashes detect corruption, not deliberate tampering. Native parsers and inference +still need process isolation and application-owned resource limits. +Read [SECURITY.md](./SECURITY.md) for guarantees, limits and private reporting. + +Training rows stay local by default, but feature names, categories, labels and +learned structure can reveal information. Schemas and environment records remain +author declarations. Model training, hosting, signing and conversion are outside +the library's scope. + +## Documentation + +The [documentation site](./docs/) includes installation, adapter guides, record +prediction and API reference. The normative contracts remain in this repository: + +- [Bundle format and verification](./docs/format.md) +- [Schema, mappings and explicit samples](./docs/format-schema.md) +- [Supported adapters and structure](./docs/format-adapters.md) +- [Sklearn state and trusted types](./docs/format-state.md) +- [Composite pipelines](./docs/format-pipeline.md) +- [ONNX layouts and bounds](./docs/format-onnx.md) + +The [playground](./playground/) provides editable examples and inspection of the +generated `.mlbundle` file. It runs scikit-learn examples in the browser and +inspects uploaded files without loading their models. + +## Contributing + +The Python library uses [uv](https://docs.astral.sh/uv/) and Python 3.14: + +```bash +uv python install 3.14 +uv sync --locked --all-extras +uv run pre-commit install +uv run ruff check . +uv run ruff format --check . +uv run pyright +uv run pytest +uv build ``` -Paths and bytes are preserved unchanged; a ModelProto is serialized once. -There is no conversion. `native_model` contains the original `data` and CPU -`session`. Native graph labels are verified; supplied labels must agree with -them. Without native/supplied labels, encoded indices are generated. ONNX has -no visualization structure. See [the exact ONNX contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md). - -## Local schema and explicit samples +Tests use the installed package and enforce 80% branch-inclusive coverage. +CI validates the built wheel on Windows and Linux. Read [AGENTS.md](./AGENTS.md) +for contribution rules and [publishing](./docs/publishing.md) for the owner-approved +release workflow. -Install a model extra plus `schema` on Python 3.14. Supply a nonempty pandas -DataFrame with string feature names in model input order. `target_names` has one -unique name per output; every target column must exist and is excluded. Without it the frame contains features only. +The landing, docs and playground use [Vite+](https://viteplus.dev/guide/) and a +pnpm workspace. With `vp` installed, run from the repository root: -```python -import pandas as pd -from sklearn.tree import DecisionTreeClassifier -from mlbundle import export, inspect, load - -training = pd.DataFrame( - [[0.0, 1.0, "cold"], [1.0, 0.0, "warm"]] * 2, - columns=["temperature", "humidity", "target"], -) -features = training.drop(columns="target") -model = DecisionTreeClassifier(random_state=7).fit(features, training["target"]) -export(model, "schema.mlbundle", training_data=training, target_names=["target"]) -view = inspect("schema.mlbundle") -assert view.manifest.features.dtypes == ["float64", "float64"] -assert view.schema["reports"][0]["labels"] == ["cold", "warm"] -assert view.samples is None -assert load("schema.mlbundle").schema == view.schema -# Samples are real data: explicitly include the first two feature rows. -export(model, "sampled.mlbundle", training_data=features, sample_rows=2) -assert inspect("sampled.mlbundle").samples == features.head(2).to_dict("records") -``` - -`schema.json` is required and uses schema version `2`, with `source` equal to -`training_data` or `default`. Without a frame, mlschema infers fields from one -synthetic row of float `1.0` values; training dtypes remain null. `onehot_separator=` -defaults to `__` and is stored in the schema (1–16 characters). `color__red`/`color__blue` -binary columns can become one category field. Native-name mappings are strings; all other origins map by zero-based feature position. Reports map by output position, -including `0` for one output, with string display labels; manifest class labels retain -their original types. Frame names must match native names in order; `feature_names=` cannot rename/reorder a supplied frame. -Training dtypes are declarations, separate from verified model dtypes. Inspect/load -validate schema mappings, reports and sample keys without pandas or mlschema. -Bundles written by 0.1.0a1 without schema, or with schema version `1`, must be re-exported. - -**Data leaving the machine:** names/dtypes, field kinds/nullability, dtype-based -steps, categories (including unused), one-hot suffixes, pair-axis metadata and -class labels. No inferred ranges, means, counts or arbitrary text/example rows. -Names/categories and learned models/structure can reveal training information. -Explicit `sample_rows=N` includes the first N feature rows, without target/index; -N must be positive and within the frame. See [schema and samples](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-schema.md). - -## Inspect, load and the CLI - -`inspect` validates archive layout, limits, manifest, hashes and schema/sample -declarations **without importing any model library**. It cannot attest that -metadata or structure matches the model. `load` additionally deserializes through -the closed adapter and recomputes native claims and structure before returning -the model and predictor. Export checks the same native round trip before writing. - -```shell -mlbundle inspect trained.mlbundle -mlbundle inspect trained.mlbundle --json -mlbundle verify trained.mlbundle +```bash +vp install +vp run check +vp run build ``` -Inspection prints metadata, members/hashes and declarations. Human values escape -terminal controls and truncate at 512 characters; `--json` is lossless ASCII-escaped -manifest JSON. `verify` requires the model extra. Errors are one bounded stderr -line without bundle content/traceback. Exit codes: **0** success, **1** bundle or -dependency rejection (`MLBundleError`), **2** usage/I/O failure. - -## Supported models and limits - -- **Sklearn:** fitted classifiers exposing `predict_proba` and regressors with recoverable feature/output counts. Tested families include trees/ensembles, linear/regularized/robust/GLM models, SVMs, PLSRegression, naive Bayes, LDA/QDA, dense brute-force neighbors, NearestCentroid, MLP and MultiOutput/RegressorChain. SimpleImputer/KNNImputer, scalers, KBinsDiscretizer, categorical encoders, univariate selectors and closed NumPy FunctionTransformer functions round-trip, including nested ColumnTransformer pipelines; see [the exact lists](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md). All-sklearn Pipeline structure stays null; composite trees expose the inner boundary. See - [tested configurations](https://github.com/UlloaSP/mlbundle/blob/main/tests/test_sklearn_families.py) and [core round trips/rejections](https://github.com/UlloaSP/mlbundle/blob/main/tests/test_sklearn.py). -- **Sklearn rejections:** custom estimators/subclasses, functions/types outside the closed lists (including lambdas and user functions), compound fitted dtypes, classifiers without probabilities, multilabel/ClassifierChain layouts, unsupported native placements, and untrusted nested storage such as fitted sparse neighbor arrays. HistGradientBoosting uses untrusted `TreePredictor` storage and is rejected. -- **Sklearn versions:** the closed class/attribute catalog is audited/tested against **1.9.1 with skops 0.16.0**, not every version allowed by `>=1.6`. Unknown state fails; matching export/load sklearn versions are recommended. Configurations must satisfy the catalog and round trip; see [state ownership](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md). -- **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native gbtree - (including XGBoost 3.4.1 dart normalized to gbtree). Closed probability/regression objectives; raw boosters, RF wrappers, subclasses, ranking/survival/custom objectives and unsupported storage are rejected. At most 100,000 trees/depth 14, with complete state/dumps; see [the mapping](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-adapters.md#implemented-xgboost-mapping). -- **ONNX:** fixed-width rank-two numeric inputs; one regression/probability target, optional native labels and dynamic/one-row batches. Unsupported layouts, external data/custom domains/functions and runtime/training configuration fail before CPU sessions. Tested with ONNX 1.23.0/Runtime 1.30.0; see [ONNX bounds](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md). - -Archive ceilings: **6 members**, **512 MiB** archive/compressed/member bytes, -**1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. Manifest: **8 MiB**; structure: **256 MiB**; schema/samples: **16 MiB each**. Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/CatBoost/ONNX: one). Callers may only lower `ArchiveLimits`. Native [state](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md)/[ONNX](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md) bounds do not cap process memory, prediction rows or CPU. - -## Bundle contents and security - -Every bundle contains `manifest.json`, `schema.json` and one native member, or the declared -`model.skops` + `model.ubj`/`model.cbm` composite pair. Single-step composites use only the leaf. Optional members are `structure.json` -(required when the adapter extracts structure) and explicitly requested -`samples.json`. No training DataFrame or arbitrary files are included. -The manifest records identity/time/writer, model parameters/counts/state, -ordered features/dtypes/origin, target labels/names, environment, member -SHA-256/sizes, references and free-form user metadata. The normative -[format specification](https://github.com/UlloaSP/mlbundle/blob/main/docs/format.md) owns these records and verification rules. - -Treat bundles as untrusted. The reader validates ZIP layout, allowed members, -limits, strict JSON/manifest and hashes; adapters use closed trust/loading paths -and recompute claims. **Hashes detect corruption, not deliberate tampering**; -there is no signing or proof of authorship, training history, quality or fairness. -Schema, samples and training-environment declarations are not authenticated. -Native parsers/inference require process isolation, memory/CPU/row limits and -maintained dependencies. See [SECURITY.md](https://github.com/UlloaSP/mlbundle/blob/main/SECURITY.md) for guarantees and reporting. - -## Development - -From the root: `uv python install 3.14`, `uv sync --locked --all-extras`, `uv run pre-commit install`. Default dev installs every extra; base: `uv sync --no-dev`. Pydantic v2 is the sole core dependency; model/schema imports are lazy. Schema uses mlschema `>=0.2.1,<0.3` on 3.14 only (0.2.1 excludes 3.15). `uv.lock` fixes tested versions; dependency edits require `uv sync` and committing the updated lockfile. - -Run `uv run ruff check .`, `uv run ruff format --check .`, `uv run pyright`, `uv run pytest` and `uv build`. Tests execute every fenced README example and write terminal/XML coverage with an 80% branch-inclusive minimum. CI checks lint/types and tests the same built wheel on Ubuntu/Windows, including clean base installs. The owner, or an agent on the owner's explicit request for that version, runs manual [publishing](https://github.com/UlloaSP/mlbundle/blob/main/docs/publishing.md) from `main` with `pypi_env` approval. Read [AGENTS.md](https://github.com/UlloaSP/mlbundle/blob/main/AGENTS.md). Agents never run or approve it on their own initiative or add publish paths, tokens or secrets. Releases, version tags, conversions, per-model environments, signing and additional model libraries remain outside scope. The project is MIT licensed. +Start a site with `vp run dev:landing`, `vp run dev:docs` or +`vp run dev:playground`. Their local ports are 4340, 4341 and 10021 respectively. +Each site also accepts `vp run dev`, `vp run build` and `vp run preview` from its +directory. Astro sites run their framework CLI through `vp exec`; the playground +uses Vite+ directly. `vp run check` runs frontend lint, landing formatting, all +three sites' type checks, and the docs and playground unit tests. Runtime and +browser tests live in `playground/`. + +Actions runs Python and frontend checks on pushes and PRs to `main` and `develop`. +After all checks pass on `main`, it deploys the three tested site builds to +Cloudflare. Manual **CI Pipeline** runs on `main` use the same gates and can +redeploy without publishing Python. A change to `project.version` on `main` +automatically starts the PyPI workflow after its version and branch preflight. +See [website deployment](./docs/deployment.md) and [publishing](./docs/publishing.md). + +## License + +MIT. The design takes ideas from MLflow's versioned manifest and native storage, +not its code. diff --git a/SECURITY.md b/SECURITY.md index f91dc70..5f1ed08 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security policy -The v0.1.0a3 development pre-alpha has not had an independent security review; +MLbundle 0.2.0 has not had an independent security review; do not accept bundles from untrusted parties. Format version 1 is not frozen. It implements strict manifest validation, secure container reading/writing, metadata-only `inspect`, export/load, sklearn, XGBoost, CatBoost and ONNX adapters, local schema inference, @@ -291,5 +291,4 @@ behavior, and the impact. Share malicious bundles or sensitive details through that private report rather than a public issue or pull request. Remove personal or confidential training data from any reproducer you submit. -Version 0.1.0a2 is published; 0.1.0a3 is not yet published. Report the affected -version and commit you tested. +Report the affected version and commit you tested. diff --git a/brand/README.md b/brand/README.md new file mode 100644 index 0000000..4b83083 --- /dev/null +++ b/brand/README.md @@ -0,0 +1,14 @@ +# MLbundle identity + +Original mark copied unchanged from `eshkra/src/assets/marks/mlbundle.png` +(708 × 960). The PNG retains its original resolution and embedded provenance. + +Outlined wordmarks use Mona Sans from the family's self-hosted Latin subset: ML at +weight 800 and width 125%, bundle at weight 300 and width 100%, matching MLform and +MLschema glyph for glyph. Font license: SIL OFL 1.1, included alongside these assets. +Wordmarks are SVG paths so GitHub and PyPI need no fonts. `wordmark-light.svg` is +graphite `#0b0b10` for light backgrounds; `wordmark-dark.svg` is `#f4f4f6` for dark ones. + +Use orange #ff6a1a with graphite and neutral backgrounds. Use MLbundle for the brand +lockup; keep `mlbundle` lowercase in package names, imports, commands, URLs and the +`.mlbundle` file extension. diff --git a/brand/mlbundle.png b/brand/mlbundle.png new file mode 100644 index 0000000..5ea5bb8 Binary files /dev/null and b/brand/mlbundle.png differ diff --git a/brand/mona-sans-LICENSE.txt b/brand/mona-sans-LICENSE.txt new file mode 100644 index 0000000..fd6728b --- /dev/null +++ b/brand/mona-sans-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Mona Sans Project Authors (https://github.com/github/mona-sans), with Reserved Font Name "Mona" MonaSans-Italic[wdth,wght].ttf: Copyright 2022 The Mona Sans Project Authors (https://github.com/github/mona-sans), with Reserved Font Name "Mona" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/brand/wordmark-dark.svg b/brand/wordmark-dark.svg new file mode 100644 index 0000000..689867c --- /dev/null +++ b/brand/wordmark-dark.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 306.88 76" role="img" aria-label="MLbundle"> +<title>MLbundle +Mona Sans: ML weight 800 width 125%; bundle weight 300 width 100%. SIL OFL 1.1. + + + + + + + + + + + diff --git a/brand/wordmark-light.svg b/brand/wordmark-light.svg new file mode 100644 index 0000000..6e250e3 --- /dev/null +++ b/brand/wordmark-light.svg @@ -0,0 +1,14 @@ + +MLbundle +Mona Sans: ML weight 800 width 125%; bundle weight 300 width 100%. SIL OFL 1.1. + + + + + + + + + + + diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..ddce69b --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.astro/ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7265737 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# mlbundle documentation site + +The Astro Starlight site for `docs.mlbundle.eshkra.dev`, package `mlbundle-docs` in the repository's +pnpm workspace. Run every command from the repository root with Vite+ (`vp`) and Node.js 22.18+, +24.11+ or 26+: + +```shell +vp install --frozen-lockfile +vp run mlbundle-docs#dev # http://127.0.0.1:4341/ +vp run mlbundle-docs#build # astro check, then a static build in docs/dist +``` + +In development the header links point to the local landing (`4340`) and playground (`10021`); +built output links to the production hosts. + +From `docs/`, use `vp run dev`, `vp run build` or `vp run preview`. These scripts +invoke Astro through `vp exec`; bare `vp dev` and `vp build` run Vite's own +commands and do not build an Astro site. + +## Single-sourced contract + +The Markdown files beside this README (`format*.md`) and the root `SECURITY.md` are the normative +contract. The site renders them in place through the `spec` collection in `src/content.config.ts`, +mapped to routes in `src/spec.ts`; relative links become site routes or GitHub links. Edit those +files, never a copy. `publishing.md` is maintainer guidance and is not rendered. + +Guides live in `src/content/docs/`. Their Python fences are checked by `uv run ruff format --check`. +The link resolver has a self-test: + +```shell +vp run mlbundle-docs#test +``` + +The brand mark is read from the repository's `brand/` directory. Actions builds, +checks and deploys this site with the other frontends; see [website deployment](deployment.md). diff --git a/docs/astro.config.ts b/docs/astro.config.ts new file mode 100644 index 0000000..6284329 --- /dev/null +++ b/docs/astro.config.ts @@ -0,0 +1,73 @@ +import { defineConfig } from "astro/config"; +import starlight from "@astrojs/starlight"; +import { fileURLToPath } from "node:url"; +import { sidebar } from "./src/navigation"; + +const repo = (path: string) => + fileURLToPath(new URL(`../${path}`, import.meta.url)).replaceAll("\\", "/"); + +export default defineConfig({ + site: "https://docs.mlbundle.eshkra.dev", + trailingSlash: "always", + devToolbar: { enabled: false }, + vite: { + // The docs read the shared brand assets at the repository root. + server: { + fs: { + allow: [fileURLToPath(new URL(".", import.meta.url)), repo("brand")], + }, + }, + }, + integrations: [ + starlight({ + title: "mlbundle", + description: + "Pickle-free, verifiable model bundles with a manifest, a local schema and model structure.", + components: { + Head: "./src/components/BrandHead.astro", + SiteTitle: "./src/components/BrandTitle.astro", + SocialIcons: "./src/components/SocialIcons.astro", + Footer: "./src/components/Footer.astro", + }, + social: [ + { + icon: "github", + label: "GitHub", + href: "https://github.com/UlloaSP/mlbundle", + }, + ], + editLink: { + baseUrl: "https://github.com/UlloaSP/mlbundle/edit/main/docs/", + }, + expressiveCode: { + // The landing's code themes, switched with the documentation theme. + themes: ["github-dark-default", "github-light-default"], + styleOverrides: { + borderRadius: "12px", + borderColor: "var(--mb-border)", + codeFontFamily: "var(--brand-font-mono)", + uiFontFamily: "var(--brand-font-sans)", + codeFontSize: "0.8125rem", + codeLineHeight: "1.7", + frames: { + shadowColor: "transparent", + editorTabBarBackground: "var(--mb-subtle)", + editorActiveTabBackground: "var(--mb-code-bg)", + editorActiveTabIndicatorTopColor: "var(--mb-ember)", + editorActiveTabIndicatorBottomColor: "transparent", + editorBackground: "var(--mb-code-bg)", + terminalTitlebarBackground: "var(--mb-subtle)", + terminalBackground: "var(--mb-code-bg)", + }, + }, + }, + customCss: [ + "./src/styles/typography.css", + "./src/styles/theme.css", + "./src/styles/content.css", + "./src/styles/home.css", + ], + sidebar, + }), + ], +}); diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..41487f1 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,85 @@ +# Website deployment + +The three websites deploy as Cloudflare Workers Static Assets. They need no +server-side Python: the playground runs Python in the visitor's browser. + +| Site | Worker | Domain | +| --- | --- | --- | +| Landing | `mlbundle-landing` | `mlbundle.eshkra.dev` | +| Documentation | `mlbundle-docs` | `docs.mlbundle.eshkra.dev` | +| Playground | `mlbundle-playground` | `playground.mlbundle.eshkra.dev` | + +Each site's `wrangler.jsonc` owns its worker name, custom domain, asset directory +and routing. Astro sites use trailing slashes and their generated 404 pages. +Cloudflare manages the custom domains and certificates during deployment. + +## Triggers and checks + +`.github/workflows/ci.yml` runs on pushes and pull requests targeting `main` or +`develop`, and supports **Run workflow** in GitHub Actions. + +- Python gates build wheel/sdist, validate metadata, lint, check types and test + the built wheel and base install on Ubuntu and Windows. +- The reusable `sites.yml` runs frontend lint, landing formatting, Astro/TypeScript + checks, docs link tests, playground unit tests and real Pyodide example tests. + It builds all three sites, validates Wrangler configuration without uploading, + and runs Chromium tests on desktop and mobile against the built playground. +- Only successful frontend runs upload `landing-build`, `docs-build` and + `playground-build`. Browser failures retain their traces for seven days. +- After **all** Python and frontend jobs pass, pushes to `main` and manual CI + runs on `main` call `deploy-sites.yml`. PRs, forks and `develop` never deploy. + +Deployment downloads each tested artifact from the same run; it does not rebuild. +It uses the locked Wrangler dependency through `vp exec`, deploys each site to +its own GitHub environment, and checks the resulting homepage title over HTTPS. +Deployments for the same site are serialized and are not cancelled midway. + +`publish.yml` runs reuse these checks, but skip website deployment. PyPI +publication starts automatically when a push to `main` changes +`project.version`, runs only from `main` through `pypi_env`, and is independent +of website deployments. No tags or GitHub releases are created; see +[publishing](publishing.md). + +## GitHub and Cloudflare setup + +The repository's Actions secrets must contain `CLOUDFLARE_API_TOKEN` and +`CLOUDFLARE_ACCOUNT_ID`. The token must authorize Workers deployments and custom +domain management for `eshkra.dev`. The zone must belong to that account and be +active in Cloudflare. Keep credentials in Actions; +never put them in a Wrangler file or commit them. + +The `landing`, `docs` and `playground` GitHub environments allow deployment only +from the `main` branch. They have no manual review gate; passing CI permits the +automatic website deployment. `pypi_env` is also limited to `main` and has no +manual review gate. + +Secrets being present does not prove their permissions or domain ownership. +The first successful Actions deployment verifies that external configuration. + +## Local validation and redeployment + +From the repository root: + +```sh +vp install --frozen-lockfile +vp run check +vp run mlbundle-playground#test:runtime +vp run build +vp run check:deploy +vp run mlbundle-playground#test:e2e:built +``` + +Install Chromium first with `vp exec playwright install chromium` from +`playground/`. `check:deploy` uses `wrangler deploy --dry-run`; it never uploads. +Node 24.14.1 and the Vite+ version pinned in `package.json` are used in Actions. + +To redeploy current `main`, select **CI Pipeline → Run workflow → main**. This +reruns every gate and does not publish to PyPI. For a rollback, revert the website +change through the usual review process and merge to `main`; the new CI run +deploys the reverted build. The repository files must be committed and pushed +before GitHub can execute a changed workflow. + +The setup follows the official [Vite+ action](https://github.com/voidzero-dev/setup-vp), +[reusable workflow](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows) +and [Wrangler configuration](https://developers.cloudflare.com/workers/wrangler/configuration/) +documentation. diff --git a/docs/format-pipeline.md b/docs/format-pipeline.md index 74e20b8..b2e3ac2 100644 --- a/docs/format-pipeline.md +++ b/docs/format-pipeline.md @@ -1,6 +1,6 @@ # Version 1 composite pipelines -Normative section of [the format contract](format.md). This pre-release format +Normative section of [the format contract](format.md). This format extension preserves reading of standalone bundles. Older readers reject composites. ## Representation and declarations diff --git a/docs/format-schema.md b/docs/format-schema.md index 0e319e5..dae7067 100644 --- a/docs/format-schema.md +++ b/docs/format-schema.md @@ -1,7 +1,7 @@ # Format 1 schema v2 and samples Normative section of [the format contract](format.md). Local inference uses -mlschema >=0.2.1,<0.3, tested with 0.2.1. Fields, source and separator are untrusted +mlschema >=0.3.0,<0.4, validated against local 0.3.0. Fields, source and separator are untrusted declarations; integrity cannot authenticate the training data or its history. ## Export inputs and defaults @@ -35,8 +35,8 @@ without builders, overrides or custom kinds. The separator is stored and read from the schema; consumers must not assume the default. Model-origin fields retain name mappings. Dataframe/declared inference preserves -named columns for grouping, then rewrites parent/one-hot `mappedTo` values -to their zero-based feature position. Series columns have no mappings. Generated +named columns for grouping, then calls `mlschema.with_positional_mappings(fields, names)` to convert +parent/one-hot targets to their zero-based feature position. Series columns have no mappings. Generated origin is inferred on positional columns directly. Labels remain feature names. For models without native names, prediction accepts 2-D NumPy arrays or nested numeric rows in manifest feature order. Adapter-supported sparse arrays and @@ -76,8 +76,8 @@ All five keys are required and unknown keys are forbidden. `source` is exactly and unknown versions raise bounded `InvalidSchemaError` requiring re-export. A missing schema member raises bounded `InvalidContainerError` saying the bundle predates the required schema and must be re-exported, through shared member rules. -Thus 0.1.0a1 bundles without schema or with schema v1 are incompatible with 0.1.0a2. -Format version remains `1`, whose pre-release wire contract is not frozen. +Thus 0.1.0a1 bundles without schema or with schema v1 require re-export for 0.2.0. +Format version remains `1`, whose wire contract is not frozen. Arrays preserve order. `fields` has 1–100,000 entries and `reports` 1–1,024. Typed records use strict types, finite numbers and no arbitrary extensibility. @@ -99,12 +99,12 @@ a top-level ordinary label must equal the feature name at that index. | `series` | `columns`: exactly two scalar subfields, with `id` equal to `field1` then `field2`, and no `mappedTo`; no nested series/one-hot groups. Labels describe pair axes. | | `onehot-category` | Special shape below. | -In 0.1.0a3 the `series` shape changed from `field1`/`field2` to MLForm's +In 0.2.0 the `series` shape changed from `field1`/`field2` to MLForm's `columns` while format 1 is not frozen. Schema version stays `"2"`: no bundle written before this change contains a series field, so a version bump would invalidate every 0.1.0a2 bundle for a shape none uses. Readers reject the old shape. -The exporter adapts mlschema's pairs in one small function until mlschema emits -this shape. Parent `label`, `required` and `mappedTo` remain unchanged: +MLschema emits this shape directly. Parent `label`, `required` and `mappedTo` +remain unchanged: ```json {"kind":"series","label":"pair","mappedTo":0,"required":true, diff --git a/docs/format-state.md b/docs/format-state.md index 977ec78..3675dca 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -1,7 +1,7 @@ # Version 1 prediction state and bounded native work -Normative section of [the format contract](format.md). Version 1 is pre-release -and not yet frozen. Bundles lacking the required prediction-state records +Normative section of [the format contract](format.md). Version 1 is not frozen. +Bundles lacking the required prediction-state records fail full claim comparison. ## Sklearn state ownership diff --git a/docs/format.md b/docs/format.md index 7a5d1ad..0bd99bc 100644 --- a/docs/format.md +++ b/docs/format.md @@ -2,10 +2,10 @@ This document defines the wire contract for `format_name = "mlbundle"` and `format_version = "1"`. Format versions are independent of package versions. -"Must" denotes a compatibility or security requirement. This pre-release +"Must" denotes a compatibility or security requirement. This contract is implemented by the manifest, secure container, inspect/export/load, sklearn/XGBoost/CatBoost/ONNX adapters, local schema, samples and prediction-state checks. -Version 1 is not frozen. Releases 0.1.0a2/a3 require schema v2; incompatible manifest/schema shapes require re-export. Valid a2 bundles without series remain compatible. The inspect/verify CLI uses these public APIs. +Version 1 is not frozen. MLbundle 0.2.0 requires schema v2; incompatible manifest/schema shapes require re-export. Valid 0.1.0a2 bundles without series remain compatible. The inspect/verify CLI uses these public APIs. The normative container rules are in [Container and resource limits](format-container.md). The normative structure, adapter and result rules are in [Adapters and structure](format-adapters.md). diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..bfa00ff --- /dev/null +++ b/docs/package.json @@ -0,0 +1,27 @@ +{ + "name": "mlbundle-docs", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vp exec astro dev --host 127.0.0.1 --port 4341", + "start": "vp run dev", + "build": "vp run typecheck && vp exec astro build", + "check": "vp run typecheck", + "preview": "vp exec astro preview --host 127.0.0.1 --port 4341", + "typecheck": "vp exec astro check", + "test": "vp exec node --test src/spec.test.ts" + }, + "dependencies": { + "@astrojs/starlight": "0.42.1", + "astro": "7.3.2", + "sharp": "0.35.5" + }, + "devDependencies": { + "@astrojs/check": "0.9.10", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + } +} diff --git a/docs/publishing.md b/docs/publishing.md index 6a8071a..ef847aa 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,61 +1,48 @@ -# Manual pre-alpha publishing +# Publishing to PyPI -Version `0.1.0a3` is the next unpublished pre-alpha; `0.1.0a2` is on PyPI. -Format version 1 is not frozen; incompatible manifest/schema shapes require -re-export. Valid a2 bundles remain compatible, with the series exception documented -in [the schema contract](format-schema.md). The security design has not had an +Version `0.2.0` is prepared by this checkout. Merging a version change into +`main` automatically starts the PyPI workflow; no one needs to start a release +run manually. Format version 1 is not frozen, so incompatible manifest/schema +shapes require re-export. Valid a2 bundles remain compatible, as documented in +[the schema contract](format-schema.md). The security design has not had an independent review; do not accept bundles from untrusted parties. -The owner, or an agent on the owner's explicit request for that specific version, -runs this workflow. Agents never run or approve it on their own initiative. -There is no API token, repository secret, tag, GitHub release or other publish path. - -## Publishing steps - -The owner, or an agent on the owner's explicit request, follows these steps: - -1. Review and merge the change into `develop`, then bring it into `main` through - the normal reviewed integration process. The workflow refuses any other ref. -2. Confirm the registered PyPI pending publisher at - [account publishing](https://pypi.org/manage/account/publishing/) has these - exact values: project `mlbundle`, owner `UlloaSP`, repository `mlbundle`, - workflow `publish.yml`, environment `pypi_env`. Confirm the GitHub `pypi_env` - environment still requires `UlloaSP` approval and permits only `main`. -3. Open [Publish to PyPI](https://github.com/UlloaSP/mlbundle/actions/workflows/publish.yml), - choose **Run workflow**, select **main**, and run it once. -4. Wait for the build/CI jobs to succeed. Review the run's commit and downloaded - `distributions` artifact, containing the `0.1.0a3` wheel and source archive. - Approve the `pypi_env` deployment when satisfied. This approval permits upload. -5. Check [mlbundle on PyPI](https://pypi.org/project/mlbundle/0.1.0a3/) and install - with `python -m pip install "mlbundle[sklearn,schema]==0.1.0a3"` on Python 3.14. - -A pending publisher does not reserve a name until a successful upload. The -[PyPI pending-publisher documentation](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) -explains this first-upload step. If publishing fails or only one artifact uploads, -inspect the PyPI state before taking further action; the preflight rejects an -existing version and the workflow does not skip existing files. - -## What the workflow checks - -`publish.yml` has two jobs. Its `build` job calls `ci.yml` from the same commit, -with publishing preflight enabled. That preflight checks `refs/heads/main`, reads -the version from `pyproject.toml`, and requires HTTP 404 from the exact PyPI -version endpoint. An existing version, other HTTP errors or a network failure -stop the run. One concurrency group prevents overlapping publish runs. - -Reusable CI builds one wheel and source archive with Python 3.14, uv 0.12.19 and -`uv_build`. Strict Twine checks validate both artifacts' metadata and README -rendering. Both OSes check artifact contents, matching wheel/sdist package bytes, -and an isolated base-wheel install. Ubuntu and Windows install that exact wheel -and run the full test suite; lint and type checks must also succeed. Tests run -from the runner's temporary directory so CatBoost writes stay outside the checkout. - -The artifact is immutable within the run. `publish` waits for all reusable CI -jobs and `pypi_env` approval, downloads `distributions` and uploads those files without -rebuilding or checking out source. Only this job has `id-token: write`; the -workflow otherwise permits only `contents: read`. Trusted Publishing exchanges -OIDC identity for short-lived PyPI credentials through the documented -[PyPA publish action](https://github.com/pypa/gh-action-pypi-publish). - -Publication and OIDC authorization cannot be tested locally. The first authorized -upload is the verification of the registered publisher and approval gate. +## Release flow + +1. Set `project.version` in `pyproject.toml` to a new version in a reviewed + change, then merge it into `main` through the normal integration process. +2. `publish.yml` checks that the push is to `main`, that the version changed + from the previous commit, and that this exact version is not already on + PyPI. Unchanged versions skip publication; an existing version, network + failure or unexpected PyPI response fails the workflow. +3. The workflow runs the full reusable CI: Python packaging, lint, type checks, + tests on Ubuntu and Windows, and all frontend checks, tests and builds. +4. If every check passes, Actions uploads the exact CI-built wheel and source + archive to [mlbundle on PyPI](https://pypi.org/project/mlbundle/). + Install with `python -m pip install "mlbundle[sklearn,schema]==0.2.0"` on + Python 3.14, replacing the example version as needed. + +The GitHub `pypi_env` environment contains the project-scoped +`MLBUNDLE_API_TOKEN` Actions secret and allows deployments from `main` only. +The workflow uses it as the PyPI token; the token value must never appear in +the repository or logs. There is no manual approval gate, tag, GitHub release +or alternate publish workflow. + +## Checks and recovery + +`publish.yml` uses a read-only preflight before calling `ci.yml`. It skips when +the version has not changed and rejects versions already on PyPI. The reusable +CI checks the same version again before building, so a release that appears +during the run blocks publication. One concurrency group prevents overlapping +publish runs. + +The CI artifact is immutable within the run. `publish` waits for all reusable +CI jobs, downloads the `distributions` artifact and uploads those files without +rebuilding or checking out source. The workflow has `contents: read`; only the +publish job receives the environment-scoped token. Website deployment is +skipped here and follows its own automatic path after successful CI on `main`, +described in [website deployment](deployment.md). + +If upload fails after PyPI accepted one artifact, inspect the version on PyPI +before retrying. The preflight refuses any existing version, and the publish +action does not skip duplicate files. diff --git a/docs/src/components/BrandHead.astro b/docs/src/components/BrandHead.astro new file mode 100644 index 0000000..2d4b9f8 --- /dev/null +++ b/docs/src/components/BrandHead.astro @@ -0,0 +1,19 @@ +--- +import { getImage } from "astro:assets"; +import mark from "../../../brand/mlbundle.png"; +import monaLicense from "../fonts/mona-sans-LICENSE.txt?url"; +import monoLicense from "../fonts/jetbrains-mono-LICENSE.txt?url"; + +const favicon = await getImage({ src: mark, width: 64, format: "png" }); +const { head } = Astro.locals.starlightRoute; +const tags = head.filter( + ({ tag, attrs }) => !(tag === "link" && String(attrs?.rel ?? "").split(/\s+/).includes("icon")), +); +--- + +{tags.map(({ tag: Tag, attrs, content }) => )} + + + + + diff --git a/docs/src/components/BrandTitle.astro b/docs/src/components/BrandTitle.astro new file mode 100644 index 0000000..428a9df --- /dev/null +++ b/docs/src/components/BrandTitle.astro @@ -0,0 +1,54 @@ +--- +import { Image } from "astro:assets"; +import mark from "../../../brand/mlbundle.png"; +const { siteTitleHref } = Astro.locals.starlightRoute; +--- + + + + + + MLbundle + + + by eshkra + + + diff --git a/docs/src/components/Footer.astro b/docs/src/components/Footer.astro new file mode 100644 index 0000000..f458194 --- /dev/null +++ b/docs/src/components/Footer.astro @@ -0,0 +1,41 @@ +--- +import Default from "@astrojs/starlight/components/Footer.astro"; +--- + + + + + diff --git a/docs/src/components/SocialIcons.astro b/docs/src/components/SocialIcons.astro new file mode 100644 index 0000000..fda8ea4 --- /dev/null +++ b/docs/src/components/SocialIcons.astro @@ -0,0 +1,54 @@ +--- +import { Icon } from "@astrojs/starlight/components"; +import { landingUrl, playgroundUrl } from "../sites"; +--- + +Product +PyPI + + mlbundle on GitHub + + + + Playground + + + diff --git a/docs/src/components/SpecPage.astro b/docs/src/components/SpecPage.astro new file mode 100644 index 0000000..a34a029 --- /dev/null +++ b/docs/src/components/SpecPage.astro @@ -0,0 +1,31 @@ +--- +import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; +import { getEntry, render } from "astro:content"; +import { specPages } from "../spec"; + +interface Props { + file: (typeof specPages)[number]["file"]; +} + +const { file } = Astro.props; +const entry = await getEntry("spec", file); +if (!entry) throw new Error(`Missing spec source ${file}`); +const { Content, headings } = await render(entry); +const page = specPages.find((spec) => spec.file === file)!; +--- + + depth > 1)} +> +

+ Normative source: {file}. This page renders that file unchanged; the guides explain it, this text defines it. +

+ +
diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts new file mode 100644 index 0000000..8cb3dd4 --- /dev/null +++ b/docs/src/content.config.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; +import { defineCollection } from "astro:content"; +import { z } from "astro/zod"; +import type { Loader } from "astro/loaders"; +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; +import { specMarkdown, specPages, specTitle } from "./spec"; + +const root = new URL("../../", import.meta.url); + +// The normative format and security files, read in place from the repository root on every build. +const specLoader: Loader = { + name: "mlbundle-spec", + load: async ({ store, renderMarkdown, watcher }) => { + store.clear(); + for (const { file, label } of specPages) { + const url = new URL(file, root); + const body = await readFile(url, "utf8"); + store.set({ + id: file, + data: { title: specTitle(body, label) }, + body, + rendered: await renderMarkdown(specMarkdown(file, body), { fileURL: url }), + }); + } + watcher?.add(specPages.map(({ file }) => new URL(file, root).pathname)); + }, +}; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), + spec: defineCollection({ loader: specLoader, schema: z.object({ title: z.string() }) }), +}; diff --git a/docs/src/content/docs/adapters/catboost.md b/docs/src/content/docs/adapters/catboost.md new file mode 100644 index 0000000..49bf4a8 --- /dev/null +++ b/docs/src/content/docs/adapters/catboost.md @@ -0,0 +1,52 @@ +--- +title: CatBoost +description: Bundle numeric, single-target symmetric CatBoost models as native CBM with complete, verified tree structure. +--- + +Install `mlbundle[catboost,schema]` (CatBoost 1.2.10 or newer; it brings matplotlib and plotly). +Models are stored as native CBM and loaded in memory. + +```python +from catboost import CatBoostClassifier + +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = CatBoostClassifier( + iterations=3, depth=2, verbose=False, allow_writing_files=False +) +model.fit(rows, ["cold", "warm"] * 2) +export(model, "catboost.mlbundle") + +bundle = load("catboost.mlbundle") +print(bundle.class_labels) # [['cold', 'warm']] +assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() +``` + +CatBoost owns its labels: `classes_` is verified on load and `class_labels=` is rejected, even when +equal. + +## Supported + +- Exact fitted `CatBoostClassifier` and `CatBoostRegressor`, one target, numeric features. +- `SymmetricTree` (oblivious trees) only. +- Classifier losses `Logloss`, `CrossEntropy` and `MultiClass`. +- Regressor losses `RMSE`, `MAE`, `Quantile`, `Huber`, `Poisson` and `MAPE`, including their + native parameter strings. + +Complete trees, feature borders, NaN handling, scale and bias, and feature importances are carried +in `structure.json` and verified on load. `predicted_label` uses CatBoost's own class prediction, +including its threshold. + +## Rejected + +`class_labels=`; categorical, text and embedding features; `Depthwise` and `Lossguide` growth; +`MultiClassOneVsAll` and other losses; multi-target and uncertainty models; rankers, bare +`CatBoost`, subclasses; custom Python objectives or metrics; unknown wrapper attributes. + +Categorical features round-trip natively in CBM, but their CTR storage has an unchecked native +loader, so mlbundle rejects them before export and again before native load. Encode categories +yourself, or use a scikit-learn prefix in a [composite pipeline](/adapters/pipelines/). + +The wrapper recovery, native-parser limits and in-memory serializer are specified in +[CatBoost mapping](/format/catboost/). diff --git a/docs/src/content/docs/adapters/onnx.md b/docs/src/content/docs/adapters/onnx.md new file mode 100644 index 0000000..c442c19 --- /dev/null +++ b/docs/src/content/docs/adapters/onnx.md @@ -0,0 +1,59 @@ +--- +title: ONNX +description: Bundle an existing self-contained tabular ONNX file unchanged, with verified signatures, labels and a CPU session on load. +--- + +Install `mlbundle[onnx,schema]`. mlbundle does not convert models to ONNX. It bundles an ONNX file +you already have, byte for byte, after validating that it is a self-contained tabular model. + +This example assumes `trained.onnx` is a classifier with two numeric features and two probability +columns, and no label output: + +```python +from pathlib import Path + +from mlbundle import export, load + +original = Path("trained.onnx").read_bytes() +export("trained.onnx", "onnx.mlbundle", class_labels=[["cold", "warm"]]) + +bundle = load("onnx.mlbundle") +assert bundle.native_model.data == original +assert bundle.native_model.session.get_providers() == ["CPUExecutionProvider"] +print(bundle.class_labels) # [['cold', 'warm']] +print(bundle.predict_proba([[0.0, 0.0]])) +``` + +`export` accepts a path, bytes or an `onnx.ModelProto` (serialized once). `native_model` holds the +original `data` and an onnxruntime `session` on the CPU execution provider. + +## Labels + +- Labels in the graph (`ZipMap`, `LinearClassifier`, `TreeEnsembleClassifier` or `SVMClassifier` + attributes) are verified; supplied `class_labels=` must agree with them. +- Without graph labels, supplied labels are declarations checked only for count; without either, + encoded indices `0..N-1` are used. +- `predicted_label` comes from the graph's label output. Without one it is `None`; mlbundle never + computes an argmax. + +## Supported graphs + +- Rank-two numeric inputs (`float32`, `float64`, `int32`, `int64`) with a fixed width from 1 to + 100,000, or several width-one inputs whose names become verified feature names. +- A batch dimension that is unknown, symbolic or exactly one. +- One target: a regression output of shape `[batch]` or `[batch, 1]`, or a probability matrix or + ZipMap sequence, optionally with a label tensor. + +Kind is determined from the graph's outputs, never declared. Verified claims include input and +output signatures, opsets, IR version, producer, model metadata properties and native labels. +ONNX has no visualization structure in version 1. + +## Rejected before any session + +External tensor data, custom operator domains, model-local functions, runtime or training +configuration, image, string or sequence inputs, multi-target regression, fixed batches above one +and outputs whose kind cannot be determined. Validation scans the protobuf with bounded work before +onnxruntime sees it. + +Tested with ONNX 1.23.0 and onnxruntime 1.30.0. The exact layouts and bounds are in +[ONNX mapping](/format/onnx/). diff --git a/docs/src/content/docs/adapters/overview.md b/docs/src/content/docs/adapters/overview.md new file mode 100644 index 0000000..8c9d80b --- /dev/null +++ b/docs/src/content/docs/adapters/overview.md @@ -0,0 +1,39 @@ +--- +title: Supported models +description: The four model libraries mlbundle supports, how each is stored, what structure it carries and where the support boundary lies. +--- + +mlbundle supports four libraries through closed adapters, plus scikit-learn pipelines that end in +XGBoost or CatBoost. There is no conversion between formats and no generic fallback: a model +outside an adapter's boundary fails on export, on the training machine. + +| Library | Extra | Stored as | Bundle member | Structure | Class labels | +| --- | --- | --- | --- | --- | --- | +| [scikit-learn](/adapters/sklearn/) | `sklearn` | skops, closed allowlist | `model.skops` | Trees, ensembles, linear, importances; `null` for other families | Native `classes_` | +| [XGBoost](/adapters/xgboost/) | `xgboost` | Native UBJSON | `model.ubj` | Complete JSON tree dumps | Supplied labels bound to a native copy, or encoded indices | +| [CatBoost](/adapters/catboost/) | `catboost` | Native CBM | `model.cbm` | Complete symmetric trees and borders | Native `classes_` | +| [ONNX](/adapters/onnx/) | `onnx` | Your file, byte for byte | `model.onnx` | `null` | Graph labels, or count-checked declarations | +| [Pipelines](/adapters/pipelines/) | `sklearn` + native | skops prefix + native leaf | `model.skops` + `model.ubj`/`model.cbm` | Leaf structure with the transformed boundary | From the final estimator | + +Every adapter owns recognition, export preparation, serialization, loading, claim and structure +extraction, and prediction. The same extraction code runs on export and on load, so the format is +defined once. + +## Shared limits + +- XGBoost, CatBoost and ONNX models have exactly one output. scikit-learn supports up to 1,024. +- At most 100,000 features and 100,000 classes per output. +- Classifiers must produce probabilities; there is no decision-only classifier support. + +## Explicitly out of scope + +mlbundle does not support: + +- other libraries, including LightGBM and PyTorch; +- converting scikit-learn models to ONNX, or any other format conversion; +- per-model environments and bundle signing; +- pickle, joblib or cloudpickle, even as an option. + +The normative mappings are in [Adapters and structure](/format/adapters/), +[Prediction state](/format/state/), [CatBoost mapping](/format/catboost/), +[ONNX mapping](/format/onnx/) and [Composite pipelines](/format/pipeline/). diff --git a/docs/src/content/docs/adapters/pipelines.md b/docs/src/content/docs/adapters/pipelines.md new file mode 100644 index 0000000..3140d73 --- /dev/null +++ b/docs/src/content/docs/adapters/pipelines.md @@ -0,0 +1,66 @@ +--- +title: Composite pipelines +description: Bundle a fitted scikit-learn Pipeline that ends in XGBoost or CatBoost, storing the prefix with skops and the final estimator natively. +--- + +A fitted `sklearn.pipeline.Pipeline` whose final step is an exact XGBoost or CatBoost wrapper is +exported as it is. The transformer prefix is stored with skops, the final estimator with its native +adapter, and load rebuilds a real `Pipeline` after verifying both parts. Install +`mlbundle[sklearn,xgboost,schema]` or `mlbundle[sklearn,catboost,schema]`. + +```python +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler +from xgboost import XGBClassifier + +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = make_pipeline(StandardScaler(), XGBClassifier(n_estimators=3, n_jobs=1)) +model.fit(rows, [0, 1, 0, 1]) +export(model, "pipeline.mlbundle", class_labels=[["cold", "warm"]]) + +bundle = load("pipeline.mlbundle") +assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() +assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() +print([member.value for member in bundle.members]) +# ['manifest.json', 'model.skops', 'model.ubj', 'schema.json', 'structure.json'] +``` + +## How it is stored + +| Part | Member | Verified on load | +| --- | --- | --- | +| Transformer prefix | `model.skops` | Step names, order, options and a fitted-state commitment | +| Final XGBoost estimator | `model.ubj` | The full XGBoost claims and tree structure | +| Final CatBoost estimator | `model.cbm` | The full CatBoost claims and tree structure | + +A single-step pipeline stores only the native leaf. The manifest reports the library as `sklearn` +with serialization `pipeline`, and `model.pipeline` records the steps, the final estimator's claims +and the **transformed-feature boundary** between them. All-scikit-learn pipelines are not composite: +they use one skops member and have no structure. + +## Labels, features and structure + +- Labels follow the final estimator: XGBoost uses the labels you bind with `class_labels=`, and its + native `classes_`/`predict` stay encoded; CatBoost keeps its native labels. +- Manifest features, schema, training dtypes and samples describe the **original inputs**. +- Trees address the **transformed features**, without attribution back to the original columns. + `mlbundle inspect` prints the boundary's width and names. +- Six members are enough for manifest, prefix, leaf, structure, schema and explicit samples. + +## Supported prefixes + +Scalers, `PCA`, `SimpleImputer` (with indicators and empty columns), `KBinsDiscretizer`, +`PolynomialFeatures`, univariate selectors, `OneHotEncoder`, `OrdinalEncoder`, identity or closed +NumPy `FunctionTransformer`, nested native-free `Pipeline` and `ColumnTransformer`. Each needs a +fitted, data-free proof of its output width. + +## Rejected + +Transformers whose width cannot be proven, `FeatureUnion`, `KNNImputer`, slice, callable or scalar +column selectors, pipeline caching (`memory`), metadata routing, and native estimators anywhere +other than the final step. + +Step names and outer options are reconstruction declarations; no name from the manifest ever +selects loading code. The full representation is in [Composite pipelines](/format/pipeline/). diff --git a/docs/src/content/docs/adapters/sklearn.md b/docs/src/content/docs/adapters/sklearn.md new file mode 100644 index 0000000..44cf8f6 --- /dev/null +++ b/docs/src/content/docs/adapters/sklearn.md @@ -0,0 +1,83 @@ +--- +title: scikit-learn +description: Which scikit-learn estimators and transformers mlbundle can bundle with skops, what structure they carry and what is rejected. +--- + +Install `mlbundle[sklearn,schema]`. Models are stored with `skops.io.dumps` and loaded with +`skops.io.loads` against a trusted-types allowlist that lives inside mlbundle. The allowlist is +never read from a bundle, and you cannot extend it. + +```python +from sklearn.ensemble import RandomForestRegressor + +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = RandomForestRegressor(n_estimators=3, random_state=0).fit( + rows, [1.0, 2.0, 1.1, 1.9] +) +export(model, "forest.mlbundle", feature_names=["temperature", "humidity"]) + +bundle = load("forest.mlbundle") +assert bundle.predict(rows) == model.predict(rows).tolist() +structure = bundle.structure +print(structure["family"], structure["estimator_shape"]) # tree_ensemble [3] +``` + +## Supported estimators + +Fitted classifiers that expose `predict_proba`, and regressors whose feature and output counts can +be recovered. Tested families include: + +- decision trees, random forests, extra trees and gradient boosting; +- linear, regularized, robust and generalized linear models; +- SVMs, `PLSRegression`, naive Bayes, LDA and QDA; +- dense brute-force neighbors and `NearestCentroid`; +- `MLPClassifier`/`MLPRegressor`; +- `MultiOutputRegressor`/`MultiOutputClassifier` and `RegressorChain`. + +Transformers that round-trip, including inside nested `ColumnTransformer` pipelines: +`SimpleImputer`, `KNNImputer`, scalers, `KBinsDiscretizer`, categorical encoders, univariate +selectors and `FunctionTransformer` with closed NumPy functions. + +The exact class, attribute and function lists are in [Prediction state](/format/state/); the +tested configurations are in +[`tests/test_sklearn_families.py`](https://github.com/UlloaSP/mlbundle/blob/main/tests/test_sklearn_families.py). + +## Structure + +| Model | `structure.json` family | +| --- | --- | +| Single tree (`tree_`) | `decision_tree` | +| Forests, extra trees, gradient boosting | `tree_ensemble` | +| Models with `coef_`/`intercept_` | `linear` | +| Models with only `feature_importances_` | `feature_importances` | +| All-scikit-learn `Pipeline`, other families | `null` | + +scikit-learn structures also carry a `prediction_state` commitment and `state_sha256`, so the +fitted state used for prediction is bound to the bundle. An all-scikit-learn pipeline has no +structure, because version 1 has no mapping from its final step back to the input features, but +its claims and state commitment still cover every step. + +## Rejected + +Export fails with `UnsupportedModelError`, listing the untrusted types where skops finds them: + +- custom estimators and subclasses; +- functions and types outside the closed lists, including lambdas and your own functions; +- compound fitted dtypes; +- classifiers without `predict_proba`; +- multilabel and `ClassifierChain` layouts; +- untrusted nested storage, such as fitted sparse neighbor arrays; +- `HistGradientBoostingClassifier`/`Regressor`, whose `TreePredictor` storage is not on the + allowlist. + +You cannot opt in to trusting a rejected type. Rewrite the model with supported components, or keep +it out of the bundle. + +## Versions + +The allowlist is audited and tested against **scikit-learn 1.9.1 with skops 0.16.0**, not every +version the `>=1.6` range allows. Unknown fitted state fails. Use the same scikit-learn version for +export and load; a failed load under a different declared version raises +`EnvironmentMismatchError` to say so. diff --git a/docs/src/content/docs/adapters/xgboost.md b/docs/src/content/docs/adapters/xgboost.md new file mode 100644 index 0000000..81ed4ab --- /dev/null +++ b/docs/src/content/docs/adapters/xgboost.md @@ -0,0 +1,60 @@ +--- +title: XGBoost +description: Bundle XGBClassifier and XGBRegressor as native UBJSON, bind original class labels and know which settings survive. +--- + +Install `mlbundle[xgboost,schema]`. Models are stored as native in-memory UBJSON and loaded with +`load_model`, never pickled. + +```python +from xgboost import XGBClassifier + +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit(rows, [0, 1, 0, 1]) +export(model, "xgboost.mlbundle", class_labels=[["cold", "warm"]]) + +bundle = load("xgboost.mlbundle") +assert isinstance(bundle.native_model, XGBClassifier) +assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() +assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() +print(bundle.class_labels) # [['cold', 'warm']] +``` + +## Class labels + +XGBoost trains on encoded indices. Supply the original labels in probability-column order, nested +even for one output. mlbundle binds them, with the prediction settings, to an attribute on a +**copy** of the booster; your model object is not changed. Load verifies that the binding is +consistent, not how the model was trained. + +- Without `class_labels=`, encoded indices are used, or an existing binding. +- The native model's `predict` keeps returning encoded indices; `predicted_label` in + [records](/guides/records/) maps the decision to your label. +- Regressors reject `class_labels=`. + +## Supported + +- Exact `XGBClassifier` and `XGBRegressor`, one target. +- Native `gbtree` storage, including XGBoost 3.4.1's `dart` option, which uses `gbtree` storage. +- Classifier objectives `binary:logistic` and `multi:softprob`. +- Regression objectives `reg:squarederror`, `reg:absoluteerror`, `reg:pseudohubererror`, + `reg:squaredlogerror`, `reg:logistic`, `count:poisson`, `reg:gamma` and `reg:tweedie`. +- At most 100,000 trees and depth 14. + +Structure is the complete list of JSON tree dumps with statistics, plus the native model record. + +## Rejected + +Raw `Booster` objects, `XGBRFClassifier`/`XGBRFRegressor`, subclasses, ranking, survival and custom +objectives, softmax-only objectives, `gblinear`, legacy `dart` storage and multiple targets. + +## What survives + +Only configuration that native XGBoost I/O preserves is recorded and verified: the learner model +parameters, objective, booster, the bound prediction settings (`missing`, `importance_type`, +`enable_categorical`) and best-iteration attributes. Learning rate, max depth, seed and thread count +do not survive native I/O and are neither recorded nor reconstructed. + +The exact mapping is in [Implemented XGBoost mapping](/format/adapters/#implemented-xgboost-mapping). diff --git a/docs/src/content/docs/getting-started/installation.md b/docs/src/content/docs/getting-started/installation.md new file mode 100644 index 0000000..17bd87e --- /dev/null +++ b/docs/src/content/docs/getting-started/installation.md @@ -0,0 +1,87 @@ +--- +title: Installation +description: Install mlbundle 0.2.0 with the extras for your model library, from PyPI or a local checkout, and check the Python and dependency ranges. +--- + +mlbundle requires **Python 3.14 or newer**. Its only core dependency is Pydantic v2; every model +library is an optional extra that is imported lazily, only when a bundle needs it. + +These pages document mlbundle 0.2.0. + +```bash +python -m pip install "mlbundle[sklearn,schema]==0.2.0" +``` + +Replace `sklearn` with the extra for your model library; the extras are listed +[below](#choose-the-extras). + +## From a local checkout + +To work from source, or before 0.2.0 is available from your package index, install a checkout of +the repository: + +```bash +git clone https://github.com/UlloaSP/mlbundle.git +cd mlbundle +python -m pip install ".[sklearn,schema]" +``` + +Inside a uv project, add the checkout as a path dependency instead (adjust the path to where you +cloned it): + +```bash +uv add ../mlbundle --extra sklearn --extra schema +``` + +## Choose the extras + +| Extra | Installs | Needed for | +| --- | --- | --- | +| `sklearn` | `scikit-learn>=1.6`, `skops>=0.16.0` | Exporting and loading scikit-learn models and pipeline prefixes. | +| `xgboost` | `xgboost>=3.4.1`, `scikit-learn>=1.2` | `XGBClassifier` and `XGBRegressor`. | +| `catboost` | `catboost>=1.2.10` | CatBoost classifiers and regressors. CatBoost brings matplotlib and plotly. | +| `onnx` | `onnx>=1.23.0`, `onnxruntime>=1.30.0` | Existing ONNX files. | +| `schema` | `mlschema>=0.3.0,<0.4` on Python 3.14 only | **Every export.** Also installs pandas. | +| `all` | All of the above | Development and mixed workloads. | + +Pick by what each machine does: + +- **Training machine:** the model extra plus `schema`, for example `mlbundle[xgboost,schema]`. + Export always infers a schema, even without training data. +- **Serving or verifying machine:** only the matching model extra, for example `mlbundle[xgboost]`. + Loading validates the schema without pandas or mlschema. +- **Metadata only:** no extra. `inspect` and `mlbundle inspect` never import a model library. + +Composite pipelines need both parts: `[sklearn,xgboost]` or `[sklearn,catboost]`. + +:::caution[Schema inference is Python 3.14 only] +The `schema` extra is limited to Python 3.14 because mlschema supports only that version. Export +therefore runs on 3.14. Inspecting and loading validate the schema without mlschema. +::: + +## Verify the installation + +```python +from importlib.metadata import version + +import mlbundle + +print(version("mlbundle")) # 0.2.0 +print(mlbundle.FORMAT_NAME, mlbundle.FORMAT_VERSION) # mlbundle 1 +``` + +The console script is installed with the package: + +```bash +mlbundle --help +``` + +## Troubleshooting + +| Symptom | Cause | Resolution | +| --- | --- | --- | +| `MissingOptionalDependencyError` naming `schema` | Export runs without pandas or mlschema. | Install `mlbundle[schema]` on Python 3.14. | +| `MissingOptionalDependencyError` naming a library | Load or `verify` needs the model's extra. | Install the extra named in the message. | +| `mlbundle: missing optional dependency; install mlbundle[xgboost].` | Same, from the CLI. | Install that extra; `inspect` works without it. | +| `EnvironmentMismatchError` on load | A skops load failed and the bundle was written with a different scikit-learn version. | Match the training scikit-learn version. | +| `No matching distribution found` | Python is older than 3.14. | Check `python --version`. | diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md new file mode 100644 index 0000000..f407d63 --- /dev/null +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -0,0 +1,100 @@ +--- +title: Quick start +description: Export a scikit-learn classifier to a .mlbundle file, inspect its metadata, load the verified model and predict. +--- + +This walkthrough uses scikit-learn. Install `mlbundle[sklearn,schema]` on Python 3.14 as described +in [Installation](/getting-started/installation/). + +## 1. Export on the training machine + +```python title="train.py" +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import export + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = DecisionTreeClassifier(random_state=7).fit(rows, ["cold", "warm"] * 2) + +view = export(model, "trained.mlbundle", feature_names=["temperature", "humidity"]) +print([member.value for member in view.members]) +# ['manifest.json', 'model.skops', 'schema.json', 'structure.json'] +``` + +`export` serializes the model with skops, loads it back, recomputes its claims and structure, and +only then publishes the file. A model with types outside mlbundle's allowlist fails here, on the +training machine, not after upload. An existing destination needs `overwrite=True`. + +The model was fitted on plain lists, so it has no native feature names: the names you pass are +recorded as `declared`. No training data was given, so the schema comes from one synthetic row. + +## 2. Inspect without loading the model + +```python +from mlbundle import inspect + +view = inspect("trained.mlbundle") +print(view.manifest.model.library, view.manifest.model.estimator_class) +# sklearn DecisionTreeClassifier +print(view.manifest.features.names, view.manifest.features.origin) +# ['temperature', 'humidity'] declared +print(view.schema["reports"][0]["labels"]) # ['cold', 'warm'] +``` + +`inspect` validates the archive, the manifest, every non-manifest member's hash and the schema, without importing +scikit-learn or deserializing anything. It therefore **cannot** tell you whether the metadata +describes the model. The command line does the same: + +```bash +mlbundle inspect trained.mlbundle +``` + +## 3. Load and predict + +```python +from mlbundle import load + +bundle = load("trained.mlbundle") +print(bundle.kind, bundle.class_name, bundle.class_labels) +# classifier DecisionTreeClassifier [['cold', 'warm']] +print(bundle.predict_proba([[0.0, 1.0]])) # [[1.0, 0.0]] +``` + +`load` performs every `inspect` check, deserializes through the scikit-learn adapter, recomputes +the hyperparameters, counts, labels and tree, and raises `ClaimMismatchError` if any of them +differs from the manifest or `structure.json`. Classifiers use `predict_proba`, regressors +`predict`; both return plain lists. Probabilities are output-major: one matrix per output, which +for a single output is the matrix itself. + +`bundle.native_model` is the real `DecisionTreeClassifier`, for native inference or explanations. +Mutating it invalidates what was verified. + +## 4. Predict from a record + +Forms and APIs send values keyed by name. Records use the schema's mappings: positions for this +model, because its names were declared rather than native. + +```python +result = bundle.predict_record({"0": "0.0", "1": "true"})[0] +print(result.labels, result.probabilities, result.predicted_label) +# ('cold', 'warm') (1.0, 0.0) cold +``` + +Strings are parsed strictly, every feature is required, and `predicted_label` is the model's own +class decision. See [Records and predictions](/guides/records/). + +## 5. Verify from the command line + +```bash +mlbundle verify trained.mlbundle +``` + +```text +Valid bundle: sklearn "DecisionTreeClassifier"; verified format, integrity, schema/samples and native model claims/structure. +``` + +## Next steps + +- Use your real training frame for dtypes and categories: [Schema and samples](/guides/schema-and-samples/). +- Bundle XGBoost, CatBoost, ONNX or a pipeline: [Supported models](/adapters/overview/). +- Understand what each check proves: [What is verified](/guides/trust/). diff --git a/docs/src/content/docs/guides/cli.md b/docs/src/content/docs/guides/cli.md new file mode 100644 index 0000000..14e94c3 --- /dev/null +++ b/docs/src/content/docs/guides/cli.md @@ -0,0 +1,73 @@ +--- +title: Command line +description: The mlbundle inspect and verify commands, their output, JSON mode and exit codes. +--- + +Installing mlbundle adds an `mlbundle` console script with two commands. Both use the same readers +as the Python API. + +```bash +mlbundle inspect trained.mlbundle +mlbundle inspect trained.mlbundle --json +mlbundle verify trained.mlbundle +``` + +## `inspect` + +Validates metadata and integrity without importing any model library, so it works with the base +install. The first line says what was and was not checked: + +```text +Metadata and integrity validated; model claims NOT verified. +Format: mlbundle 1 +Bundle id: aa6f6b39-574a-499b-a0ff-de4cdd334050 +Created: 2026-10-19T11:17:51.949658+00:00 +Writer: "0.2.0" +Model: sklearn "DecisionTreeClassifier" (classifier) +Counts: features=2, outputs=1, classes=[2] +Features (declared): ["temperature", "humidity"] +Class labels (output-major): [["cold", "warm"]] +Target names (output-major): null +Members: + manifest.json: validated (no self-integrity record) + model.skops: 13065 bytes sha256=24d7cbab1d775ab279dc8ebc3c762b551b397ebf9b4917ac3c4474ff611b8813 + schema.json: 361 bytes sha256=ea62a6a7502ae8ea30b45b61bb18d8e31bd2ec55f026d9fbf0c05ef42d01ff25 + structure.json: 526 bytes sha256=66204da26781a4e2a800a1f3ed246ab1a43e8c97ced73d06a2640f897eeda03c +Training: {"python": "3.14.7", "implementation": "CPython", "os": "Windows", "os_version": "11", "architecture": "AMD64"} +Packages: {"catboost": "1.2.10", "mlbundle": "0.2.0", "mlschema": "0.3.0", ...} +ONNX environment: null +Present: schema=yes, structure=yes, samples=no +User metadata: {} +``` + +Composite pipelines add their step names, final estimator and transformed-feature boundary. +Values in the human summary escape terminal control characters and are truncated at 512 characters, +so a hostile bundle cannot rewrite your terminal. + +`--json` prints the full validated manifest instead: lossless, ASCII-escaped JSON, suitable for +`jq` or archiving. + +## `verify` + +Loads the bundle through its adapter and recomputes every model-derived claim and the structure. It +needs the model's extra: + +```text +Valid bundle: sklearn "DecisionTreeClassifier"; verified format, integrity, schema/samples and native model claims/structure. +``` + +## Errors and exit codes + +Errors are a single bounded line on stderr naming the failure category. They never print bundle +content or a traceback. + +| Exit code | Meaning | Example stderr | +| --- | --- | --- | +| `0` | Success. | | +| `1` | The bundle was rejected, or a dependency is missing. | `mlbundle: InvalidContainerError: bundle rejected.` | +| `1` | | `mlbundle: missing optional dependency; install mlbundle[xgboost].` | +| `2` | Usage or I/O failure. | `mlbundle: FileNotFoundError: I/O failed.` | +| `2` | | `mlbundle: invalid arguments; use --help.` | + +Use `verify` in CI or before publishing a bundle elsewhere; use `inspect` where model libraries are +not installed or should not run. diff --git a/docs/src/content/docs/guides/export.md b/docs/src/content/docs/guides/export.md new file mode 100644 index 0000000..47682e8 --- /dev/null +++ b/docs/src/content/docs/guides/export.md @@ -0,0 +1,144 @@ +--- +title: Export a bundle +description: Every export argument, how feature names and class labels are resolved, what the manifest records, and how the file is published. +--- + +`export` runs on the training machine. It rejects unsupported models there, before any file +exists, and publishes a bundle only after it has been read back and verified. + +```python +def export( + model: object, + destination: str | PathLike[str], + *, + training_data: object | None = None, + feature_names: Sequence[str] | None = None, + class_labels: Sequence[Sequence[Label]] | None = None, + target_names: Sequence[str] | None = None, + sample_rows: int | None = None, + onehot_separator: str = "__", + metadata: Mapping[str, JsonValue] | None = None, + overwrite: bool = False, + limits: ArchiveLimits | None = None, +) -> MetadataView +``` + +Export always needs the `schema` extra, even without `training_data`. + +## Arguments + +| Argument | Meaning | +| --- | --- | +| `model` | A fitted, supported model: see [Supported models](/adapters/overview/). For ONNX, a path, bytes or a `ModelProto`. | +| `destination` | Path of the `.mlbundle` file to write. | +| `training_data` | Optional nonempty pandas DataFrame, used locally for schema inference and dtypes. Never stored. | +| `feature_names` | Names for models without native names. Ignored in favour of native names; must match the frame's columns when a frame is given. | +| `class_labels` | Output-major original labels, nested even for one output. For XGBoost, ONNX (verified against graph labels when present, count-checked otherwise) and composites ending in XGBoost. Rejected by scikit-learn, CatBoost and regressors. | +| `target_names` | One unique name per output, in output order. With a frame, these columns are excluded. A bare string is rejected. | +| `sample_rows` | Positive integer: include the first N feature rows as `samples.json`. Requires a frame. See [samples](/guides/schema-and-samples/#explicit-samples). | +| `onehot_separator` | Separator for one-hot groups, 1–16 characters, stored in the schema. | +| `metadata` | Free JSON object recorded as `user_metadata`. | +| `overwrite` | Replace an existing destination. Without it, an existing file raises `FileExistsError`. | +| `limits` | Lower [`ArchiveLimits`](/reference/limits/) for this bundle. Ceilings cannot be raised. | + +`export` returns the same [`MetadataView`](/reference/api/#metadataview) that `inspect` would +produce for the new file. + +## Feature names and origin + +Every bundle records an ordered list of feature names and where they came from: + +| Origin | When | Names | Record keys and schema mappings | +| --- | --- | --- | --- | +| `model` | The model exposes native names, such as `feature_names_in_` after fitting on a DataFrame. | Native names | Names | +| `dataframe` | No native names, `training_data` supplied. | Frame columns | Zero-based positions | +| `declared` | No native names, `feature_names=` supplied. | Your names | Zero-based positions | +| `generated` | None of the above. | `feature_0` … `feature_{N-1}` | Zero-based positions | + +Native names always win. Declared names are labels: they do not add name-based prediction or +reordering. For models without native names, predict with a 2-D NumPy array or nested numeric rows +in manifest feature order. + +```python +import pandas as pd +from sklearn.linear_model import LogisticRegression + +from mlbundle import export + +frame = pd.DataFrame( + {"temperature": [0.0, 1.0, 0.1, 0.9], "humidity": [1.0, 0.0, 0.9, 0.1]} +) +model = LogisticRegression().fit(frame, ["cold", "warm"] * 2) + +view = export(model, "named.mlbundle", training_data=frame, overwrite=True) +print(view.manifest.features.origin, view.manifest.features.names) +# model ['temperature', 'humidity'] +print(view.manifest.features.dtypes) # ['float64', 'float64'] +``` + +## Class labels and targets + +Class labels are **output-major**: a list per output, in probability-column order. Where they come +from depends on the adapter: + +- **scikit-learn and CatBoost** own their `classes_`; `class_labels=` is rejected. +- **XGBoost** is trained on encoded indices. Supply the original labels with + `class_labels=[["cold", "warm"]]`; they are bound to a native copy of the model and verified on + load. Without them, encoded indices are used. See [XGBoost](/adapters/xgboost/). +- **ONNX** labels from the graph are verified; supplied labels must agree. A graph without labels + accepts count-checked declarations. See [ONNX](/adapters/onnx/). +- **Composites** delegate labels to the final native estimator. + +Regressors reject `class_labels=`. `target_names=` is a separate declaration that names each output, +and with a training frame tells mlbundle which columns to exclude from inference, dtypes and samples. + +## What the manifest records + +`manifest.json` holds: + +- identity: format name and version, a random `bundle_id`, `created_at`, `writer_version`; +- model: library, class, kind, hyperparameters, feature/output/class counts and serialization; +- features: ordered names, origin, training dtypes and verified model dtypes; +- target: output-major class labels and target names; +- environment: Python version and implementation, OS and version, architecture and installed + package versions; +- integrity: the SHA-256 and size of every other member; +- `user_metadata`: your `metadata=`, at most 1,024 top-level keys, 64 KiB and depth 8. + +Model-derived values, including model-exposed `model_dtypes`, are claims that `load` recomputes. +The environment, training `dtypes` and user metadata are declarations: recorded, never authenticated. The exact fields are in the +[format 1 manifest](/format/#manifest). + +```python +view = export( + model, + "described.mlbundle", + training_data=frame, + metadata={"name": "weather", "metrics": {"accuracy": 1.0}}, + overwrite=True, +) +print(view.manifest.user_metadata["name"]) # weather +``` + +## Publication + +The writer builds a deterministic ZIP in memory, validates it with the reader, then publishes it +atomically with a hard link or replacement. Where hard links are unavailable it falls back to +exclusive creation, which removes a partial file on failure but exposes the destination while +copying. + +## Export failures + +Validation failures happen before anything is published (with the exclusive-creation fallback, a +failed copy is removed but was briefly visible): + +| Error | Typical cause | +| --- | --- | +| `UnsupportedModelError` | The model class, objective, layout or a contained type is outside the adapter's closed boundary. For scikit-learn the message lists the untrusted types. | +| `InvalidSchemaError` | Frame columns do not match native names, a target column is missing, or report labels collide. | +| `InvalidSamplesError` | `sample_rows` is zero, too large, not an integer, or a cell cannot be converted. | +| `MissingOptionalDependencyError` | The `schema` extra or the model's extra is not installed. | +| `LimitExceededError` | A member or the archive exceeds the selected limits. | +| `FileExistsError` | The destination exists and `overwrite` is false. | + +The full list is in [Errors](/reference/errors/). diff --git a/docs/src/content/docs/guides/inspect-and-load.md b/docs/src/content/docs/guides/inspect-and-load.md new file mode 100644 index 0000000..69ea76c --- /dev/null +++ b/docs/src/content/docs/guides/inspect-and-load.md @@ -0,0 +1,112 @@ +--- +title: Inspect and load +description: Read a bundle's metadata without touching the model, or load it through a closed adapter that recomputes every model-derived claim. +--- + +mlbundle has two readers with different guarantees. Choose by what you need to trust. + +| | `inspect` | `load` | +| --- | --- | --- | +| Archive layout, members, resource limits | Validated | Validated | +| Strict manifest and format version | Validated | Validated | +| SHA-256 and size of every non-manifest member | Verified | Verified | +| Schema and samples against manifest claims | Validated | Validated | +| Imports a model library | **Never** | The matching adapter only | +| Deserializes the model | **Never** | Through the closed adapter | +| Hyperparameters, counts, labels, dtypes, structure | Read as claims | **Recomputed and compared** | +| Returns | `MetadataView` | `LoadedBundle` with model and predictor | + +Both accept a path, `bytes` or a binary stream, and an optional lowered +[`ArchiveLimits`](/reference/limits/). The ZIP is read in memory by allowed member names and never +extracted to disk. + +## `inspect`: metadata only + +```python +from mlbundle import inspect + +view = inspect("trained.mlbundle") +manifest = view.manifest +print(manifest.format_version, manifest.writer_version) # 1 0.2.0 +print(manifest.model.n_features, manifest.model.n_outputs) # 2 1 +print([member.value for member in view.members]) +# ['manifest.json', 'model.skops', 'schema.json', 'structure.json'] +print(view.samples) # None +``` + +`MetadataView` has `manifest`, `members`, `schema` and `samples`. Use it to list, index or review +bundles on a machine without model libraries. + +:::caution[Inspection does not verify the model] +A bundle's author can write any manifest and recompute its hashes. `inspect` checks that the file is +well formed and internally consistent, not that the metadata, labels or structure describe the +model inside. Only `load` (or `mlbundle verify`) establishes that agreement. +::: + +## `load`: a verified model + +```python +from pathlib import Path + +from mlbundle import load + +bundle = load(Path("trained.mlbundle").read_bytes()) # paths, bytes or streams +print(bundle.library, bundle.class_name, bundle.kind) +# sklearn DecisionTreeClassifier classifier +print( + bundle.feature_names, bundle.feature_origin +) # ('temperature', 'humidity') declared +print(bundle.structure["family"]) # decision_tree +``` + +`load` runs every `inspect` check, then: + +1. resolves the adapter from a closed list (`sklearn`, `xgboost`, `catboost`, `onnx`), never from a + class name in the manifest; +2. deserializes with skops against mlbundle's own trusted types, native UBJSON or CBM in memory, or + an ONNX CPU session after graph validation; +3. extracts claims and structure with the same code the writer used and compares them as canonical + JSON bytes, raising `ClaimMismatchError` on any difference. + +### `LoadedBundle` + +| Member | Contents | +| --- | --- | +| `manifest`, `members`, `schema`, `samples` | As in `MetadataView`, validated. | +| `structure` | Decoded `structure.json`, or `None` for families without extractable structure. | +| `native_model` | The native object: estimator, `Pipeline`, XGBoost or CatBoost wrapper, or an ONNX model holding `data` and `session`. | +| `kind`, `library`, `class_name` | From the verified manifest. | +| `feature_names`, `feature_origin` | Ordered names and their origin. | +| `class_labels` | Output-major labels, or `None` for regressors. | +| `predict_proba(data)` | Classifiers only. One probability matrix per output; the matrix itself for one output. | +| `predict(data)` | Regressors only. Row-major values. | +| `row_from_record(record)`, `predict_record(record)` | See [Records and predictions](/guides/records/). | + +Calling `predict_proba` on a regressor, or `predict` on a classifier, raises `ValueError`. Results +are plain Python lists. `native_model` stays available for native inference and explanations; +mutating it invalidates what `load` verified. + +## Structure + +`structure.json` describes the model a reviewer would visualise: decision trees, tree ensembles, +linear coefficients, feature importances, XGBoost tree dumps or CatBoost's symmetric trees. It is +required whenever the adapter can extract it and is compared on load, so a reviewer never sees a +tree that differs from the model used for prediction. All-scikit-learn pipelines and ONNX have +`None`. The families and fields are in [Adapters and structure](/format/adapters/#structurejson). + +## Version diagnostics + +Some failures add a hint without adding a compatibility gate: + +- `EnvironmentMismatchError` when a skops load fails and the bundle declares a different + scikit-learn version than the one installed; +- `InvalidManifestError` mentioning a different writer version when a manifest fails validation. + +Format 1 is not frozen; incompatible changes may require re-export. Bundles written by 0.1.0a1, or +with schema version 1, must be re-exported. + +## Treat every bundle as untrusted + +Validation does not sandbox native parsers or inference. A runtime that accepts uploads still owns +authentication, process isolation and memory, CPU and row limits. See +[What is verified](/guides/trust/) and the [security policy](/security/). diff --git a/docs/src/content/docs/guides/records.md b/docs/src/content/docs/guides/records.md new file mode 100644 index 0000000..8165ff9 --- /dev/null +++ b/docs/src/content/docs/guides/records.md @@ -0,0 +1,126 @@ +--- +title: Records and predictions +description: Predict from arrays or from form-style records keyed by the bundle's schema, and read the frozen per-output results. +--- + +A loaded bundle predicts from two kinds of input: arrays in feature order, and single records keyed +by the schema. Both go through the same verified predictor. + +## Arrays and DataFrames + +```python +from mlbundle import load + +bundle = load("trained.mlbundle") +print(bundle.predict_proba([[0.0, 1.0], [1.0, 0.0]])) # [[1.0, 0.0], [0.0, 1.0]] +``` + +- Classifiers use `predict_proba`; results are **output-major**: one matrix per output, or the + matrix itself when there is one output. +- Regressors use `predict`; results are row-major values. +- For models without native feature names, pass a 2-D NumPy array or nested numeric rows in + manifest feature order. Declared names do not enable name-based selection. +- Adapter-supported DataFrames and sparse arrays keep their native restrictions. + +## Records + +`predict_record` takes one mapping, as a form or JSON API would send it, and returns one frozen +`OutputPrediction` per output. + +```python +import pandas as pd +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import export, load + +frame = pd.DataFrame( + {"temperature": [0.0, 1.0, 0.1, 0.9], "humidity": [1.0, 0.0, 0.9, 0.1]} +) +model = DecisionTreeClassifier(random_state=7).fit(frame, ["cold", "warm"] * 2) +export(model, "named.mlbundle", training_data=frame) + +bundle = load("named.mlbundle") +(result,) = bundle.predict_record( + {"temperature": "0.05", "humidity": 0.95, "note": "ignored"} +) +print(result.labels, result.probabilities, result.predicted_label) +# ('cold', 'warm') (1.0, 0.0) cold +``` + +### Keys + +| Feature origin | Keys | +| --- | --- | +| `model` (native names) | Feature names. | +| `dataframe`, `declared`, `generated` | Zero-based positions, as integers (`0`) or canonical decimal strings (`"0"`). Supplying both forms for one position is an error. | + +Every feature is required. Extra keys are ignored **without iterating the record**, so the work +depends on the feature count, not the size of what a client sent. + +### Values + +- Finite Python `int`, `float` and `bool` values. +- Strings, stripped of ASCII whitespace and at most 64 characters: `true`/`false` in any case, or + decimals such as `42`, `-1.5`, `.5`, `1.` and `2e-3`. +- `None` becomes NaN only when the schema field has `required: false`; for one-hot options the + group's requirement applies. + +Category fields still need the numeric value the model was trained on. Records convert strings to +numbers; they do not encode category names. + +`row_from_record` returns the validated numeric row without predicting, for example to feed an +explainer with the same input: + +```python +print(bundle.row_from_record({"temperature": 0.2, "humidity": " 1e0 "})) # [0.2, 1.0] +``` + +### Errors + +Invalid records raise `PredictionInputError`, which is also a `ValueError`. Messages name the key, +escaped and bounded, but **never echo the value**: + +```text +Prediction record must be a mapping. +Missing features: "humidity". +Feature "temperature": a required feature cannot be None. +Feature "temperature": number must be finite. +Feature "temperature": expected a decimal or true/false string. +Feature 0: both integer and string keys are present. +``` + +Missing-key errors list at most five keys and then a count of the rest. + +## `OutputPrediction` + +```python +@dataclass(frozen=True) +class OutputPrediction: + output: int # report position, in output order + kind: ModelKind # classifier or regressor + labels: tuple[str, ...] | None # report labels, in probability order + probabilities: tuple[float, ...] | None + value: float | None # regression value + predicted_label: str | None +``` + +`predicted_label` is the report label of the **model's own class decision**: scikit-learn +`predict`, the XGBoost wrapper's `predict`, CatBoost's class prediction including its threshold, +or the ONNX graph's label output. It can differ from the label with the largest probability, and +mlbundle never substitutes an argmax. It is `None` for regressors and for ONNX classifiers without +a label output. + +```python +regression = load("reg.mlbundle") # a LinearRegression bundle +print(regression.predict_record({"temperature": 0.5, "humidity": 0.5})) +# (OutputPrediction(output=0, kind=, labels=None, +# probabilities=None, value=2.5, predicted_label=None),) +``` + +## Pipelines that select columns by name + +scikit-learn pipelines whose fitted column selectors use names receive a one-row pandas DataFrame +from `predict_record`; this needs pandas, which the `schema` extra installs. Other models receive a +numeric row, preserving their native categorical handling. + +The normative record rules are in [Runtime record input](/format/schema/#runtime-record-input). diff --git a/docs/src/content/docs/guides/schema-and-samples.md b/docs/src/content/docs/guides/schema-and-samples.md new file mode 100644 index 0000000..577a816 --- /dev/null +++ b/docs/src/content/docs/guides/schema-and-samples.md @@ -0,0 +1,158 @@ +--- +title: Schema and samples +description: How mlbundle infers schema.json locally with mlschema, what it reveals about the training data, and how to include sample rows explicitly. +--- + +Every bundle carries `schema.json`: the input fields a form or API needs, plus one report per +model output. It is inferred **on the training machine** by +[mlschema](https://docs.mlschema.eshkra.dev/). The training DataFrame itself never enters the +bundle. + +## Infer from your training frame + +Pass the frame you trained on. With `target_names`, those columns are excluded from inference, +dtypes and samples; without it, every column must be a feature. + +```python +import pandas as pd +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import export + +training = pd.DataFrame( + { + "age": [34, 51, 29, 46], + "plan": pd.Categorical( + ["basic", "pro", "basic", "team"], + categories=["basic", "pro", "team", "free"], + ), + "color__red": [1, 0, 0, 1], + "color__blue": [0, 1, 1, 0], + "income": [52000.0, None, 61000.0, 48000.0], + "churned": ["no", "yes", "no", "yes"], + } +) +features = training.drop(columns="churned") +encoded = features.assign( + plan=features["plan"].cat.codes, income=features["income"].fillna(0) +) +model = DecisionTreeClassifier(random_state=0).fit( + encoded.to_numpy(), training["churned"] +) + +view = export(model, "churn.mlbundle", training_data=training, target_names=["churned"]) +print(view.manifest.features.origin) # dataframe +print( + view.manifest.features.dtypes +) # ['int64', 'category', 'int64', 'int64', 'float64'] +``` + +The resulting `schema.json`, keys sorted: + +```json +{ + "schema_version": "2", + "source": "training_data", + "onehot_separator": "__", + "fields": [ + { "kind": "number", "label": "age", "mappedTo": 0, "required": true, "step": 1.0 }, + { + "kind": "category", + "label": "plan", + "mappedTo": 1, + "options": ["basic", "pro", "team", "free"], + "required": true + }, + { + "kind": "onehot-category", + "label": "color", + "options": [ + { "label": "red", "mappedTo": 2, "value": "red" }, + { "label": "blue", "mappedTo": 3, "value": "blue" } + ], + "required": true + }, + { "kind": "number", "label": "income", "mappedTo": 4, "required": false, "step": 0.1 } + ], + "reports": [ + { + "kind": "classifier", + "label": "Predicted class", + "labels": ["no", "yes"], + "mappedTo": 0, + "showClassProbabilities": true + } + ] +} +``` + +What happened: + +- `color__red` and `color__blue` are binary columns sharing a prefix before the separator, so they + became one `onehot-category` field. Change the separator with `onehot_separator=` (1–16 + characters); it is stored in the schema. +- `income` has a missing value, so it is `required: false`. +- `plan` keeps its categorical order, **including the unused `free`**. +- The model was fitted on an array, so mappings are zero-based positions. A model with native + feature names maps by name instead. +- The report labels are the string form of the model's classes, in probability-column order. + +Frame columns must be unique strings, in model input order, and must equal native names when the +model has them. `feature_names=` cannot rename or reorder a supplied frame. + +## Without training data + +Without `training_data`, mlschema infers from one synthetic row of `1.0` values, one column per +feature. Fields become required `number` fields with `step: 0.1`, except that named columns sharing +a prefix before the separator can still be grouped as one-hot. `source` is `"default"` and the +training dtypes are `null`: a synthetic row is not evidence about your data. + +## What leaves the training machine + +| Included | Not included | +| --- | --- | +| Feature names and labels | Training rows (unless you request samples) | +| Field kinds and nullability | Numeric or date minima, maxima, means | +| Category values, including unused ones | Row counts | +| One-hot names and suffixes | Example values, defaults, free-text values | +| Dtype strings and dtype-based numeric steps | The DataFrame index | +| Pair-axis names for series fields | | +| Class labels | | + +These declarations can still be sensitive: column and category names reveal what you trained on. +The model, its learned structure and your `metadata=` can independently reveal training +information. Leaving out samples does not prevent model memorization. + +## Explicit samples + +Samples are **real user data**, so they are only ever included when you ask. `sample_rows=N` +includes exactly the first N feature rows of the frame, without target columns or the index. + +```python +from mlbundle import inspect + +export(model, "sampled.mlbundle", training_data=features, sample_rows=2) +print(inspect("sampled.mlbundle").samples) +# [{'age': 34, 'color__blue': 0, 'color__red': 1, 'income': 52000.0, 'plan': 'basic'}, +# {'age': 51, 'color__blue': 1, 'color__red': 0, 'income': None, 'plan': 'pro'}] +``` + +- N must be a positive integer within the frame; `0`, `True` and larger values raise + `InvalidSamplesError`, as does `sample_rows` without `training_data`. +- There is no random sampling or clipping. Shuffle the frame yourself if the first rows are not + representative. +- Missing values become `null`, timestamps ISO 8601 strings, categorical and NumPy scalars plain + JSON values. Bytes, complex values, timedeltas and arbitrary objects are rejected. +- Schema and samples are limited to 16 MiB each and JSON depth 32. + +## Reading schemas + +`inspect` and `load` validate `schema.json` without mlschema or pandas: every feature is covered +exactly once, mappings match the feature origin, and reports match the manifest's class labels. +`load` additionally verifies those labels against the model. The schema's `source`, separator and +nullability remain declarations; nothing can attest how the training data looked. + +Bundles written by 0.1.0a1 without a schema, or with schema version `1`, must be re-exported. + +The full rules, including series fields and cell conversions, are in +[Schema and samples](/format/schema/). diff --git a/docs/src/content/docs/guides/trust.md b/docs/src/content/docs/guides/trust.md new file mode 100644 index 0000000..1418fcd --- /dev/null +++ b/docs/src/content/docs/guides/trust.md @@ -0,0 +1,66 @@ +--- +title: What is verified +description: Which parts of a bundle mlbundle checks, which it recomputes from the model, and which remain the author's unauthenticated declarations. +--- + +The author of a bundle may be an attacker. mlbundle therefore treats everything in it as a claim +and sorts each claim into one of three groups. + +## Recomputed from the model on `load` + +`load` and `mlbundle verify` rebuild the model through a closed adapter and extract these values +with the same code the writer used. Any difference in canonical JSON bytes raises +`ClaimMismatchError`. + +- library, estimator class and kind; +- hyperparameters the native format can recover; +- feature, output and per-output class counts; +- native feature names and model-exposed dtypes; +- class labels owned by the model, and labels bound to XGBoost or present in an ONNX graph; +- prediction state commitments (`state_sha256`) for scikit-learn and composites; +- the complete `structure.json`: trees, ensembles, coefficients, importances, XGBoost dumps and + CatBoost's symmetric trees; +- schema reports, through the verified class labels. + +A reviewer never sees a tree that differs from the model used for prediction. + +## Validated by `inspect` and `load` + +These checks need no model library: + +- ZIP layout, ZIP64 records, CRCs and compression; only allowed member names, no paths, symlinks, + duplicates, encryption or bytes outside the archive layout; +- member count, sizes, compression ratio and JSON depth within [limits](/reference/limits/); +- a strict manifest with exact types and a known format version; +- SHA-256 and size of every member except the manifest, which cannot contain its own hash; +- schema coverage, mappings by feature origin and report shape; sample keys. + +## Declarations nobody can verify + +| Declaration | Why it cannot be checked | +| --- | --- | +| Training environment and package versions | Recorded by the writer; any author can edit them. | +| Training dtypes | Describe a frame that is not in the bundle. | +| Schema `source`, separator and nullability | Inference history is not reproducible from the bundle. | +| Declared or frame feature names | Labels for a model without native names; count and alignment are checked. | +| `target_names`, `user_metadata`, composite step names | Free author text. | +| XGBoost label binding history | Load verifies consistency with the native copy, not how it was trained. | +| ONNX labels for a label-free graph | Only their count is checked. | +| Samples | Real rows as the author wrote them. | + +## What hashes do not prove + +SHA-256 detects corruption, not tampering: an attacker can replace a member and update its hash. +There is no signing, so nothing proves who trained a model, its training history, quality or +fairness. Bundle signing is out of scope. + +## What a runtime still owns + +Pickle-free formats do not sandbox native parsers or inference. A service that accepts uploads must +still provide authentication, authorization, process isolation and memory, CPU and row limits, and +keep model libraries patched. mlbundle's safety ceilings bound archive parsing and model state, not +process memory or the rows you predict. + +mlbundle has not had an independent security review. Do not accept bundles from untrusted parties. +Read the [security policy](/security/) for the threat model, guarantees and private vulnerability +reporting. diff --git a/docs/src/content/docs/index.md b/docs/src/content/docs/index.md new file mode 100644 index 0000000..0c7cc20 --- /dev/null +++ b/docs/src/content/docs/index.md @@ -0,0 +1,89 @@ +--- +title: mlbundle documentation +description: Package a trained model, its manifest, a locally inferred schema and model structure in one verifiable .mlbundle file, without pickle. +tableOfContents: false +hero: + title: One verifiable file for a trained model. + tagline: mlbundle puts the model in its native format, a manifest of model and training-environment metadata, a locally inferred input schema and the model's structure into one .mlbundle file. Before it hands you the model, the reader recomputes every model-derived claim and rejects disagreement. + actions: + - text: Quick start + link: /getting-started/quick-start/ + variant: primary + - text: What is verified + link: /guides/trust/ + variant: secondary + - text: Format 1 contract + link: /format/ + variant: minimal +--- + +```python title="train.py" +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import export + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = DecisionTreeClassifier(random_state=7).fit(rows, ["cold", "warm"] * 2) +export(model, "trained.mlbundle", feature_names=["temperature", "humidity"]) +``` + +```python title="serve.py" +from mlbundle import load + +bundle = load("trained.mlbundle") # verified before it is returned +print(bundle.predict_proba([[0.0, 1.0]])) # [[1.0, 0.0]] +print(bundle.predict_record({"0": "0.0", "1": "true"})[0].predicted_label) # cold +``` + +No pickle, joblib or cloudpickle is involved at any step. scikit-learn models are stored with +skops against a closed allowlist, XGBoost as native UBJSON, CatBoost as native CBM, and ONNX files +byte for byte. Loading rebuilds the model through mlbundle's own adapter, extracts its claims and +structure with the same code the writer used, and rejects the bundle when they disagree. + +:::note[Before you accept uploads] +Format 1 is not frozen, so a future change may require re-exporting bundles. The security design +has not had an independent review: do not accept bundles from untrusted parties. +::: + +## Find your path + + + +## Why a bundle + +Uploading a pickled model and a DataFrame leaves three gaps that mlbundle closes: + +| Gap | What mlbundle does | +| --- | --- | +| Loading a pickle can execute code. | Closed adapters use skops, native UBJSON/CBM or the original ONNX file. There is no pickle fallback. | +| The training versions are lost. | The manifest records Python, OS, architecture and installed package versions next to model and feature metadata. | +| Training data leaves the machine. | Schema inference runs locally. Rows are included only when you ask for them explicitly. | + +mlbundle is the training-side companion to MLSuite, whose runtime accepts only `.mlbundle` +uploads. It is open source under the MIT license and part of [eshkra](https://eshkra.dev). Read +the source on [GitHub](https://github.com/UlloaSP/mlbundle). diff --git a/docs/src/content/docs/reference/api.md b/docs/src/content/docs/reference/api.md new file mode 100644 index 0000000..c46d611 --- /dev/null +++ b/docs/src/content/docs/reference/api.md @@ -0,0 +1,141 @@ +--- +title: Python API +description: Signatures and contracts of the public mlbundle symbols exported from the package root. +--- + +The package root exports the supported API. Everything else, including adapters and container +internals, is private and can change without notice. + +```python +from mlbundle import ( + FORMAT_NAME, # "mlbundle" + FORMAT_VERSION, # "1" + ArchiveLimits, + LoadedBundle, + Manifest, + MetadataView, + OutputPrediction, + export, + inspect, + load, +) +``` + +Model records are strict, frozen Pydantic models: they reject unknown fields and type coercion. + +## Functions + +### `export` + +```python +def export( + model: object, + destination: str | PathLike[str], + *, + training_data: object | None = None, + feature_names: Sequence[str] | None = None, + class_labels: Sequence[Sequence[Label]] | None = None, + target_names: Sequence[str] | None = None, + sample_rows: int | None = None, + onehot_separator: str = "__", + metadata: Mapping[str, JsonValue] | None = None, + overwrite: bool = False, + limits: ArchiveLimits | None = None, +) -> MetadataView +``` + +Publishes a bundle only after its claims survive a native serialization round trip and the reader +validates it. Requires the `schema` extra. `Label` is a JSON scalar class label. Every argument is +described in [Export a bundle](/guides/export/#arguments). + +### `inspect` + +```python +def inspect(source: BundleSource, *, limits: ArchiveLimits | None = None) -> MetadataView +``` + +Validates the container, manifest, JSON bounds, hashes, schema and samples without importing a +model library. It cannot attest model-derived claims. + +### `load` + +```python +def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> LoadedBundle +``` + +Everything `inspect` does, then deserializes through the closed adapter and verifies recomputed +claims and structure. `BundleSource` is `str | PathLike[str] | bytes | BinaryIO`. + +## Results + +### `MetadataView` + +Frozen dataclass returned by `export` and `inspect`. + +| Field | Type | +| --- | --- | +| `manifest` | `Manifest` | +| `members` | `tuple[MemberName, ...]`, in canonical order | +| `schema` | `dict[str, JsonValue] \| None` | +| `samples` | `list[dict[str, JsonValue]] \| None` | + +### `LoadedBundle` + +Frozen dataclass returned by `load`, constructed only after verification. + +| Member | Type / behaviour | +| --- | --- | +| `manifest`, `members`, `schema`, `samples` | As in `MetadataView`. | +| `structure` | `dict[str, JsonValue] \| None` | +| `native_model` | The native model object. | +| `predictor` | The adapter's uniform predictor. | +| `kind` | `ModelKind` | +| `library` | `Library` | +| `class_name` | `str`, the estimator class | +| `feature_names` | `tuple[str, ...]` | +| `feature_origin` | `FeatureOrigin` | +| `class_labels` | `list[list[Label]] \| None`, output-major | +| `predict_proba(data)` | `list`, classifiers only; otherwise `ValueError` | +| `predict(data)` | `list[float]` or rows of floats, regressors only; otherwise `ValueError` | +| `row_from_record(record)` | `list[float]` in feature order | +| `predict_record(record)` | `tuple[OutputPrediction, ...]`, one per output | + +### `OutputPrediction` + +Frozen dataclass with `output`, `kind`, `labels`, `probabilities`, `value` and `predicted_label`. +See [Records and predictions](/guides/records/#outputprediction). + +## Manifest records + +| Class | Fields | +| --- | --- | +| `Manifest` | `format_name`, `format_version`, `bundle_id`, `created_at`, `writer_version`, `model`, `features`, `target`, `environment`, `integrity`, `references`, `user_metadata` | +| `ModelInfo` | `estimator_class`, `kind`, `hyperparameters`, `n_features`, `n_classes`, `n_outputs`, `library`, `serialization`, `pipeline` and state fields | +| `FeatureInfo` | `names`, `dtypes` (training), `model_dtypes` (verified), `origin` | +| `TargetInfo` | `class_labels`, `names` | +| `TrainingEnvironment` | `python_version`, `python_implementation`, `os`, `os_version`, `architecture`, `packages`, `onnx` | +| `OnnxInfo`, `Opset` | ONNX opsets, IR version and producer | +| `IntegrityRecord` | `sha256`, `size` | +| `MemberReferences` | `schema`, `structure`, `samples` | +| `ModelClaims` | The model-derived claims the reader recomputes | + +Field types and verification rules are normative in [Format 1](/format/#manifest). + +## Enums + +All are `StrEnum`s whose values appear in the manifest. + +| Enum | Values | +| --- | --- | +| `Library` | `sklearn`, `xgboost`, `catboost`, `onnx` | +| `ModelKind` | `classifier`, `regressor` | +| `SerializationFormat` | `skops`, `ubjson`, `cbm`, `onnx`, `pipeline` | +| `FeatureOrigin` | `model`, `dataframe`, `declared`, `generated` | +| `MemberName` | `manifest.json`, `model.skops`, `model.ubj`, `model.cbm`, `model.onnx`, `schema.json`, `structure.json`, `samples.json` | + +## Limits and errors + +[`ArchiveLimits`](/reference/limits/) lowers the safety ceilings. Every exception mlbundle raises +for a bundle or model derives from `MLBundleError`; see [Errors](/reference/errors/). + +`mlbundle.__version__` is the installed package version. diff --git a/docs/src/content/docs/reference/development.md b/docs/src/content/docs/reference/development.md new file mode 100644 index 0000000..104a1cc --- /dev/null +++ b/docs/src/content/docs/reference/development.md @@ -0,0 +1,49 @@ +--- +title: Development +description: Set up a checkout of mlbundle, run its checks, and build this documentation site locally. +--- + +## The library + +mlbundle uses [uv](https://docs.astral.sh/uv/) on Python 3.14, [Ruff](https://docs.astral.sh/ruff/) +and [Pyright](https://microsoft.github.io/pyright/). The default development install includes +every extra. + +```bash +uv python install 3.14 +uv sync --locked --all-extras +uv run pre-commit install +``` + +Run the checks from the repository root: + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pyright +uv run pytest +uv build +``` + +`pytest` measures branch coverage with an 80% minimum. CI runs lint and type checks and tests the +built wheel on Ubuntu and Windows, including clean installs without extras. After changing +dependency metadata, run `uv sync` and commit the updated `uv.lock`. + +Contributor rules, including the security guardrails and the 300-line file limit, are in +[AGENTS.md](https://github.com/UlloaSP/mlbundle/blob/main/AGENTS.md). Report vulnerabilities +privately as described in the [security policy](/security/#reporting-a-vulnerability). + +## This documentation + +The site is an Astro Starlight project in `docs/`, part of the repository's pnpm workspace and run +with Vite+ (`vp`). Use Node.js 22.18+, 24.11+ or 26+: + +```bash +vp install --frozen-lockfile +vp run mlbundle-docs#dev # http://127.0.0.1:4341/ +vp run mlbundle-docs#build +``` + +The [Format 1 contract](/format/) and [security policy](/security/) pages render the repository's +`docs/format*.md` and `SECURITY.md` files directly, so the specification has one source. Edit +those files, not a copy. diff --git a/docs/src/content/docs/reference/errors.md b/docs/src/content/docs/reference/errors.md new file mode 100644 index 0000000..93aafbd --- /dev/null +++ b/docs/src/content/docs/reference/errors.md @@ -0,0 +1,69 @@ +--- +title: Errors +description: The MLBundleError hierarchy, when each error is raised, and how messages stay bounded. +--- + +Validation of bundles, models, schemas and prediction inputs fails with a bounded `MLBundleError`. +Messages never echo record values or native third-party exception text. They can name a member, a +claim, an escaped and truncated feature key, an extra or a writer/version hint. Filesystem failures +and API misuse remain builtin exceptions, listed at the end. + +```python +from mlbundle import MLBundleError, load + +try: + bundle = load(b"not a bundle") +except MLBundleError as error: + print(type(error).__name__) # InvalidContainerError +``` + +## Hierarchy + +```text +MLBundleError +├── InvalidContainerError +├── LimitExceededError +├── UnsupportedFormatVersionError +├── InvalidManifestError +├── InvalidJSONError +├── IntegrityMismatchError +├── ClaimMismatchError +├── UnsupportedModelError +├── InvalidModelError +│ ├── EnvironmentMismatchError +│ └── PredictionInputError (also a ValueError) +├── InvalidSchemaError +├── InvalidSamplesError +├── MissingOptionalDependencyError +└── AdapterNotImplementedError +``` + +## When they are raised + +| Error | Raised when | Attributes | +| --- | --- | --- | +| `InvalidContainerError` | The ZIP violates the container rules: unknown, duplicate or missing members, unsafe names, bad records or CRCs, a missing schema in an old bundle. | | +| `LimitExceededError` | An input exceeds an [archive or member limit](/reference/limits/). | | +| `UnsupportedFormatVersionError` | The manifest declares a format version other than `1`. | | +| `InvalidManifestError` | The manifest fails strict validation; may hint at a different writer version. | | +| `InvalidJSONError` | A JSON member is malformed or uses an unsupported value. | | +| `IntegrityMismatchError` | A member's SHA-256 or size disagrees with the manifest. | `member` | +| `ClaimMismatchError` | A recomputed model claim or the structure differs from the bundle. | `claim` | +| `UnsupportedModelError` | Export of a model outside the adapter boundary. | `untrusted_types` | +| `InvalidModelError` | Native model content is malformed or violates the adapter contract. | | +| `EnvironmentMismatchError` | A skops load failed and the bundle declares a different scikit-learn version. | | +| `PredictionInputError` | Invalid prediction input or record. | | +| `InvalidSchemaError` | Training inputs or `schema.json` violate the schema contract. | | +| `InvalidSamplesError` | Invalid `sample_rows`, cell conversion or sample keys. | | +| `MissingOptionalDependencyError` | A needed extra is not installed. | `extra` | +| `AdapterNotImplementedError` | A library has no implemented adapter. | `library` | + +Identifiers in `member`, `claim`, `extra` and `library` are restricted to short ASCII names; +anything else is reported as `unrecognized`. + +Not every failure is an `MLBundleError`: an existing destination without `overwrite=True` raises +`FileExistsError`, and calling `predict` on a classifier (or `predict_proba` on a regressor) raises +`ValueError`. + +The [command line](/guides/cli/#errors-and-exit-codes) maps `MLBundleError` to exit code 1 and +I/O failures to exit code 2. diff --git a/docs/src/content/docs/reference/limits.md b/docs/src/content/docs/reference/limits.md new file mode 100644 index 0000000..3385f3d --- /dev/null +++ b/docs/src/content/docs/reference/limits.md @@ -0,0 +1,58 @@ +--- +title: Limits +description: The archive, member and model ceilings mlbundle enforces before allocating or loading content, and how to lower them. +--- + +Readers and writers enforce these ceilings before allocating or decompressing content. They are +safety maxima: you can lower them for a call, never raise them. + +## `ArchiveLimits` + +| Field | Default and maximum | +| --- | --- | +| `max_members` | 6 (minimum 2) | +| `max_archive_bytes` | 512 MiB | +| `max_compressed_bytes` | 512 MiB | +| `max_member_bytes` | 512 MiB | +| `max_total_bytes` | 1 GiB, all members uncompressed | +| `max_compression_ratio` | 200 | +| `max_manifest_bytes` | 8 MiB | +| `max_structure_bytes` | 256 MiB | +| `max_schema_bytes` | 16 MiB | +| `max_samples_bytes` | 16 MiB | + +A specific member cap never exceeds `max_member_bytes`. + +```python +from mlbundle import ArchiveLimits, inspect + +strict = ArchiveLimits( + max_archive_bytes=16 * 1024 * 1024, max_structure_bytes=4 * 1024 * 1024 +) +view = inspect("trained.mlbundle", limits=strict) +``` + +Values above a ceiling are rejected by validation. Pass the same object to `export`, `inspect` or +`load`; an upload service would typically lower `max_archive_bytes` to its request size. + +## Fixed ceilings + +| Item | Ceiling | +| --- | --- | +| JSON nesting depth | 32 | +| Features | 100,000 | +| Classes per output | 100,000 | +| Outputs | 1,024; XGBoost, CatBoost and ONNX: 1 | +| `user_metadata` | 1,024 top-level keys, 64 KiB, depth 8 | +| Hyperparameters | 4,096 keys, 1 MiB, depth 16 | +| XGBoost | 100,000 trees, depth 14 | +| Record string values | 64 characters | + +## What limits do not cover + +These ceilings bound archive parsing and the native state mlbundle validates. They do not cap +process memory, prediction rows or CPU time during inference. A runtime must enforce those itself. + +The normative container limits are in [Container and limits](/format/container/), native work +ceilings in [Prediction state](/format/state/#work-and-allocation-ceilings) and ONNX bounds in +[ONNX mapping](/format/onnx/). diff --git a/docs/src/fonts/jetbrains-mono-LICENSE.txt b/docs/src/fonts/jetbrains-mono-LICENSE.txt new file mode 100644 index 0000000..8f7ed67 --- /dev/null +++ b/docs/src/fonts/jetbrains-mono-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/src/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 b/docs/src/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..01769d9 Binary files /dev/null and b/docs/src/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 differ diff --git a/docs/src/fonts/jetbrains-mono-latin-wght-normal.woff2 b/docs/src/fonts/jetbrains-mono-latin-wght-normal.woff2 new file mode 100644 index 0000000..cd5102a Binary files /dev/null and b/docs/src/fonts/jetbrains-mono-latin-wght-normal.woff2 differ diff --git a/docs/src/fonts/mona-sans-LICENSE.txt b/docs/src/fonts/mona-sans-LICENSE.txt new file mode 100644 index 0000000..fd6728b --- /dev/null +++ b/docs/src/fonts/mona-sans-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Mona Sans Project Authors (https://github.com/github/mona-sans), with Reserved Font Name "Mona" MonaSans-Italic[wdth,wght].ttf: Copyright 2022 The Mona Sans Project Authors (https://github.com/github/mona-sans), with Reserved Font Name "Mona" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/src/fonts/mona-sans-latin-ext-standard-normal.woff2 b/docs/src/fonts/mona-sans-latin-ext-standard-normal.woff2 new file mode 100644 index 0000000..76057f5 Binary files /dev/null and b/docs/src/fonts/mona-sans-latin-ext-standard-normal.woff2 differ diff --git a/docs/src/fonts/mona-sans-latin-standard-normal.woff2 b/docs/src/fonts/mona-sans-latin-standard-normal.woff2 new file mode 100644 index 0000000..f6e67b1 Binary files /dev/null and b/docs/src/fonts/mona-sans-latin-standard-normal.woff2 differ diff --git a/docs/src/navigation.ts b/docs/src/navigation.ts new file mode 100644 index 0000000..9be3166 --- /dev/null +++ b/docs/src/navigation.ts @@ -0,0 +1,48 @@ +import { specPages } from "./spec"; + +// Sidebar order follows the reader's path: start, package, consume, choose an adapter, look up. +export const sidebar = [ + { + label: "Start", + items: [ + { label: "Overview", slug: "" }, + { label: "Installation", slug: "getting-started/installation" }, + { label: "Quick start", slug: "getting-started/quick-start" }, + ], + }, + { + label: "Guides", + items: [ + { label: "Export a bundle", slug: "guides/export" }, + { label: "Inspect and load", slug: "guides/inspect-and-load" }, + { label: "Command line", slug: "guides/cli" }, + { label: "Schema and samples", slug: "guides/schema-and-samples" }, + { label: "Records and predictions", slug: "guides/records" }, + { label: "What is verified", slug: "guides/trust" }, + ], + }, + { + label: "Adapters", + items: [ + { label: "Supported models", slug: "adapters/overview" }, + { label: "scikit-learn", slug: "adapters/sklearn" }, + { label: "XGBoost", slug: "adapters/xgboost" }, + { label: "CatBoost", slug: "adapters/catboost" }, + { label: "ONNX", slug: "adapters/onnx" }, + { label: "Composite pipelines", slug: "adapters/pipelines" }, + ], + }, + { + label: "Reference", + items: [ + { label: "Python API", slug: "reference/api" }, + { label: "Errors", slug: "reference/errors" }, + { label: "Limits", slug: "reference/limits" }, + { label: "Development", slug: "reference/development" }, + ], + }, + { + label: "Format 1 contract", + items: specPages.map(({ label, route }) => ({ label, link: route })), + }, +]; diff --git a/docs/src/pages/format/[...part].astro b/docs/src/pages/format/[...part].astro new file mode 100644 index 0000000..a4c2189 --- /dev/null +++ b/docs/src/pages/format/[...part].astro @@ -0,0 +1,17 @@ +--- +import SpecPage from "../../components/SpecPage.astro"; +import { specPages } from "../../spec"; + +export function getStaticPaths() { + return specPages + .filter(({ route }) => route.startsWith("/format/")) + .map(({ file, route }) => ({ + params: { part: route.slice("/format/".length, -1) || undefined }, + props: { file }, + })); +} + +const { file } = Astro.props; +--- + + diff --git a/docs/src/pages/security.astro b/docs/src/pages/security.astro new file mode 100644 index 0000000..7373e4a --- /dev/null +++ b/docs/src/pages/security.astro @@ -0,0 +1,5 @@ +--- +import SpecPage from "../components/SpecPage.astro"; +--- + + diff --git a/docs/src/sites.ts b/docs/src/sites.ts new file mode 100644 index 0000000..61c28b6 --- /dev/null +++ b/docs/src/sites.ts @@ -0,0 +1,5 @@ +// Sibling sites: local dev servers while developing, the production hosts in built output. +const dev = import.meta.env.DEV; + +export const landingUrl = dev ? "http://localhost:4340/" : "https://mlbundle.eshkra.dev/"; +export const playgroundUrl = dev ? "http://localhost:10021/" : "https://playground.mlbundle.eshkra.dev/"; diff --git a/docs/src/spec.test.ts b/docs/src/spec.test.ts new file mode 100644 index 0000000..3d80085 --- /dev/null +++ b/docs/src/spec.test.ts @@ -0,0 +1,49 @@ +// Run with `vp run test`: the spec pages must keep resolving every relative link they contain. +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { test } from "node:test"; +import { resolveSpecLink, specMarkdown, specPages, specTitle } from "./spec.ts"; + +const root = new URL("../../", import.meta.url); +const github = "https://github.com/UlloaSP/mlbundle/blob/main/"; + +test("relative spec links become site routes with anchors", () => { + assert.equal(resolveSpecLink("docs/format.md", "format-onnx.md"), "/format/onnx/"); + assert.equal( + resolveSpecLink("docs/format.md", "format-adapters.md#implemented-xgboost-mapping"), + "/format/adapters/#implemented-xgboost-mapping", + ); + assert.equal(resolveSpecLink("docs/format-state.md", "format.md"), "/format/"); + assert.equal(resolveSpecLink("docs/format-schema.md", "../SECURITY.md"), "/security/"); + assert.equal(resolveSpecLink("SECURITY.md", "docs/format-schema.md"), "/format/schema/"); +}); + +test("other repository files link to GitHub, absolute links are untouched", () => { + assert.equal(resolveSpecLink("SECURITY.md", "tests/test_cli.py"), `${github}tests/test_cli.py`); + assert.equal(resolveSpecLink("docs/format.md", "../AGENTS.md#guardrails"), `${github}AGENTS.md#guardrails`); + assert.equal(resolveSpecLink("docs/format.md", "#members"), "#members"); + assert.equal(resolveSpecLink("docs/format.md", "https://example.com/a.md"), "https://example.com/a.md"); +}); + +test("rendering drops only the title heading", () => { + const body = "# Title\r\n\nSee [x](format.md).\n\n## Section\n"; + assert.equal(specTitle(body, "fallback"), "Title"); + assert.equal(specMarkdown("docs/format-onnx.md", body), "\nSee [x](/format/).\n\n## Section\n"); +}); + +test("every relative link in the real spec files resolves", () => { + for (const { file } of specPages) { + const body = readFileSync(new URL(file, root), "utf8"); + assert.notEqual(specTitle(body, ""), "", `${file} has a title`); + for (const [, url] of body.matchAll(/\]\(([^)\s]+)\)/g)) { + const resolved = resolveSpecLink(file, url!); + if (resolved.startsWith(github)) { + const path = resolved.slice(github.length).split("#")[0]!; + assert.ok(existsSync(new URL(path, root)), `${file}: ${url} points to a missing file`); + } else if (!/^(https?:|#)/.test(resolved)) { + const route = resolved.split("#")[0]; + assert.ok(specPages.some((page) => page.route === route), `${file}: ${url}`); + } + } + } +}); diff --git a/docs/src/spec.ts b/docs/src/spec.ts new file mode 100644 index 0000000..dbcd850 --- /dev/null +++ b/docs/src/spec.ts @@ -0,0 +1,49 @@ +// The normative contract lives in the repository's Markdown files; the site renders them unchanged. +const repository = "https://github.com/UlloaSP/mlbundle"; + +/** Repository file (relative to the root) and its route on this site. */ +export const specPages = [ + { file: "docs/format.md", route: "/format/", label: "Format 1" }, + { file: "docs/format-container.md", route: "/format/container/", label: "Container and limits" }, + { file: "docs/format-adapters.md", route: "/format/adapters/", label: "Adapters and structure" }, + { file: "docs/format-state.md", route: "/format/state/", label: "Prediction state" }, + { file: "docs/format-schema.md", route: "/format/schema/", label: "Schema and samples" }, + { file: "docs/format-pipeline.md", route: "/format/pipeline/", label: "Composite pipelines" }, + { file: "docs/format-catboost.md", route: "/format/catboost/", label: "CatBoost mapping" }, + { file: "docs/format-onnx.md", route: "/format/onnx/", label: "ONNX mapping" }, + { file: "SECURITY.md", route: "/security/", label: "Security policy" }, +] as const; + +const byFile = new Map(specPages.map(({ file, route }) => [file, route])); + +const posix = (path: string) => path.replaceAll("\\", "/"); + +/** Resolves a relative link written for GitHub to its route here, or to the file on GitHub. */ +export function resolveSpecLink(from: string, url: string): string { + if (/^([a-z]+:|#|\/)/i.test(url)) return url; + const [path = "", hash] = url.split("#"); + const parts = posix(from).split("/").slice(0, -1); + for (const part of path.split("/")) { + if (part === "..") parts.pop(); + else if (part && part !== ".") parts.push(part); + } + const target = parts.join("/"); + const route = byFile.get(target); + const anchor = hash ? `#${hash}` : ""; + return route ? `${route}${anchor}` : `${repository}/blob/main/${target}${anchor}`; +} + +/** The spec's first-level heading, used as the page title. */ +export function specTitle(body: string, fallback: string): string { + return body.match(/^# (.+)$/m)?.[1]?.trim() ?? fallback; +} + +/** + * Prepares a spec for rendering without changing the file: drops the first-level heading, which + * Starlight renders as the title, and points repository-relative links at this site or GitHub. + */ +export function specMarkdown(file: string, body: string): string { + return body + .replace(/^# .+\r?\n/m, "") + .replace(/\]\(([^)\s]+)\)/g, (_, url: string) => `](${resolveSpecLink(file, url)})`); +} diff --git a/docs/src/styles/content.css b/docs/src/styles/content.css new file mode 100644 index 0000000..3930a9a --- /dev/null +++ b/docs/src/styles/content.css @@ -0,0 +1,134 @@ +/* Reading surface: landing typography at documentation density. */ +code, +pre, +kbd { + font-feature-settings: "calt" 0; +} + +.content-panel { + border-top-color: var(--mb-border); +} + +h1#_top { + font-weight: 700; + font-stretch: 104%; + letter-spacing: -0.035em; + line-height: 1.05; +} + +.sl-markdown-content :is(h2, h3, h4) { + font-weight: 650; + letter-spacing: -0.02em; + color: var(--mb-text); +} + +.sl-markdown-content h2 { + margin-top: 2.75rem; + padding-top: 0.25rem; +} + +.sl-markdown-content :is(p, li) { + text-wrap: pretty; +} + +.sl-markdown-content a:not(:where(.not-content *)) { + color: var(--mb-ember-ink); + text-decoration-color: color-mix(in oklab, var(--mb-ember-ink) 40%, transparent); + text-underline-offset: 0.2em; +} + +.sl-markdown-content a:not(:where(.not-content *)):hover { + text-decoration-color: currentColor; +} + +.sl-markdown-content :not(pre) > code { + padding: 0.1em 0.35em; + border: 1px solid var(--mb-border); + border-radius: 6px; + background: var(--mb-subtle); + font-size: 0.86em; + overflow-wrap: anywhere; +} + +/* Tables: hairline rows, readable cells, horizontal scroll instead of squashed columns. */ +.sl-markdown-content table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + font-size: 0.9rem; +} + +.sl-markdown-content :is(th, td) { + padding: 0.6rem 0.9rem 0.6rem 0; + border-bottom: 1px solid var(--mb-border); + vertical-align: top; +} + +.sl-markdown-content th { + border-bottom-color: var(--mb-border-strong); + color: var(--mb-text); + font-weight: 600; + text-align: start; +} + +.sl-markdown-content td code { + overflow-wrap: normal; + white-space: nowrap; +} + +/* Asides keep Starlight semantics with brand surfaces. */ +.starlight-aside { + border-radius: 10px; + border-inline-start-width: 3px; +} + +.starlight-aside--note, +.starlight-aside--tip { + --sl-color-asides-text-accent: var(--mb-ember-ink); + --sl-color-asides-border: var(--mb-ember); + background: var(--mb-ember-soft); +} + +/* Steps: ember counters like the landing's numbered start section. */ +.sl-steps > li::before { + background: var(--mb-subtle); + color: var(--mb-ember-ink); + box-shadow: inset 0 0 0 1px var(--mb-border-strong); +} + +/* Starlight tabs share the landing's segmented look. */ +starlight-tabs [role="tablist"] { + border-bottom-color: var(--mb-border); +} + +starlight-tabs [role="tab"][aria-selected="true"] { + border-color: var(--mb-ember); + color: var(--mb-text); +} + +/* Cards and link cards. */ +.sl-markdown-content .sl-link-card, +.sl-markdown-content .card { + border-color: var(--mb-border); + border-radius: 12px; + background: var(--mb-bg); + box-shadow: none; +} + +.sl-markdown-content .sl-link-card:hover { + border-color: var(--mb-border-strong); + background: var(--mb-subtle); +} + +.expressive-code .frame { + box-shadow: none; +} + +.sl-markdown-content caption { + padding-bottom: 0.5rem; + color: var(--mb-text-3); + font-size: 0.8125rem; + font-weight: 600; + text-align: start; +} diff --git a/docs/src/styles/home.css b/docs/src/styles/home.css new file mode 100644 index 0000000..03ee132 --- /dev/null +++ b/docs/src/styles/home.css @@ -0,0 +1,77 @@ +/* Home hero: the landing's headline scale inside the documentation column. */ +.hero { + gap: 1.5rem; + padding-block: 1.5rem 2rem; + border-bottom: 0; +} + +.hero h1 { + font-size: clamp(2.25rem, 1.5rem + 2.8vw, 3.25rem); + font-weight: 700; + font-stretch: 104%; + letter-spacing: -0.04em; + line-height: 1.04; +} + +.hero .tagline { + max-width: 40rem; + color: var(--mb-text-2); + font-size: 1.125rem; +} + +.hero .sl-link-button { + border-radius: 999px; + font-weight: 600; +} + +.hero .sl-link-button.primary { + border-color: transparent; + background: var(--mb-text); + color: var(--mb-bg); +} + +.hero .sl-link-button.secondary { + border-color: var(--mb-border-strong); + background: transparent; + color: var(--mb-text); +} + +/* Path cards: three-column index of the documentation groups. */ +.path-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14.5rem, 1fr)); + gap: 0.875rem; + margin: 1.5rem 0 2rem; +} + +.path-card { + display: grid; + align-content: start; + gap: 0.35rem; + padding: 1rem 1.1rem 1.1rem; + border: 1px solid var(--mb-border); + border-radius: 12px; + color: inherit; + text-decoration: none; +} + +.path-card:hover { + border-color: var(--mb-border-strong); + background: var(--mb-subtle); +} + +.path-card strong { + color: var(--mb-text); + font-size: 1rem; +} + +.path-card span { + color: var(--mb-text-2); + font-size: 0.875rem; + line-height: 1.5; +} + +.path-card code { + color: var(--mb-ember-ink); + font-size: 0.8125rem; +} diff --git a/docs/src/styles/theme.css b/docs/src/styles/theme.css new file mode 100644 index 0000000..496aa60 --- /dev/null +++ b/docs/src/styles/theme.css @@ -0,0 +1,171 @@ +/* + * The eshkra palette shared with eshkra.dev, the MLForm and MLschema docs, mapped onto Starlight. + * `--mb-*` names the brand values; Starlight's grays are derived from them so its components agree. + */ +:root, +::backdrop { + --mb-bg: #0a0a0d; + --mb-subtle: #111116; + --mb-surface: #131318; + --mb-code-bg: #0d0d11; + --mb-border: #24242c; + --mb-border-strong: #34343e; + --mb-text: #f4f4f6; + --mb-text-2: #b6b6c2; + --mb-text-3: #8e8e9b; + --mb-ember: #ff8a3d; + --mb-ember-ink: #ff9a55; + --mb-ember-soft: rgb(255 138 61 / 0.14); + + --sl-color-accent-low: #3a1a08; + --sl-color-accent: #ff8a3d; + --sl-color-accent-high: #ffc49b; + --sl-color-white: var(--mb-text); + --sl-color-gray-1: #e4e4ea; + --sl-color-gray-2: var(--mb-text-2); + --sl-color-gray-3: var(--mb-text-3); + --sl-color-gray-4: #5c5c68; + --sl-color-gray-5: var(--mb-border); + --sl-color-gray-6: var(--mb-subtle); + --sl-color-black: var(--mb-bg); + --sl-color-text: #d9d9e0; + --sl-color-text-accent: var(--mb-ember-ink); + --sl-color-bg-nav: color-mix(in oklab, var(--mb-bg) 88%, transparent); + --sl-color-bg-sidebar: var(--mb-bg); + --sl-color-bg-inline-code: var(--mb-subtle); + --sl-color-hairline: var(--mb-border); + --sl-color-hairline-light: var(--mb-border); + --sl-color-hairline-shade: var(--mb-border); + + --sl-font: var(--brand-font-sans); + --sl-font-mono: var(--brand-font-mono); + --sl-text-h1: clamp(2.125rem, 1.6rem + 1.8vw, 2.75rem); + --sl-text-h2: 1.75rem; + --sl-text-h3: 1.3125rem; + --sl-text-h4: 1.0625rem; + --sl-line-height: 1.7; + --sl-nav-height: 4rem; + --sl-content-width: 48rem; + --sl-sidebar-width: 17.5rem; +} + +:root[data-theme="light"], +[data-theme="light"] ::backdrop { + --mb-bg: #ffffff; + --mb-subtle: #f7f7f9; + --mb-surface: #ffffff; + --mb-code-bg: #fbfbfc; + --mb-border: #e7e7ee; + --mb-border-strong: #d2d2dc; + --mb-text: #0b0b10; + --mb-text-2: #474756; + --mb-text-3: #666675; + --mb-ember: #e8590c; + --mb-ember-ink: #c2410c; + --mb-ember-soft: rgb(232 89 12 / 0.09); + + --sl-color-accent-low: #fff1e8; + --sl-color-accent: #c2410c; + --sl-color-accent-high: #8a2e07; + --sl-color-gray-1: #24242e; + --sl-color-gray-4: #9a9aa8; + --sl-color-gray-7: #fbfbfc; + --sl-color-text: #26262f; +} + +::selection { + background: color-mix(in oklab, var(--mb-ember) 28%, transparent); +} + +:focus-visible { + outline: 2px solid var(--mb-ember-ink); + outline-offset: 3px; +} + +/* Header: the landing's thin sticky bar, blurred over the page. Starlight draws its one hairline. */ +header.header { + backdrop-filter: blur(14px); +} + +.parent-brand { + padding-left: 0.9rem; + border-left: 1px solid var(--mb-border-strong); + color: var(--mb-text-3); + font-size: 0.8125rem; + text-decoration: none; + white-space: nowrap; +} + +.parent-brand:hover { + color: var(--mb-ember-ink); +} + +/* Search reads as a quiet field rather than a second toolbar. */ +site-search button[data-open-modal] { + border-color: var(--mb-border); + border-radius: 999px; + background: var(--mb-subtle); +} + +site-search button[data-open-modal]:hover { + border-color: var(--mb-border-strong); +} + +/* Sidebar: subtle group headings, ember marker on the current page. */ +.sidebar-pane { + border-inline-end: 1px solid var(--mb-border); +} + +.sidebar-content .top-level > li > details > summary .group-label > span, +.sidebar-content .top-level > li > .group-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--mb-text); +} + +.sidebar-content a { + border-radius: 8px; + font-size: 0.875rem; + color: var(--mb-text-2); +} + +.sidebar-content a:hover { + color: var(--mb-text); + background: var(--mb-subtle); +} + +.sidebar-content a[aria-current="page"], +.sidebar-content a[aria-current="page"]:hover { + background: var(--mb-ember-soft); + color: var(--mb-ember-ink); + font-weight: 600; + box-shadow: inset 2px 0 0 var(--mb-ember); +} + +/* Table of contents mirrors the sidebar's current-page marker. */ +starlight-toc a { + color: var(--mb-text-3); +} + +starlight-toc a[aria-current="true"] { + color: var(--mb-ember-ink); +} + +/* Pagination cards use the landing's window surface. */ +.pagination-links a { + border-color: var(--mb-border); + border-radius: 12px; + box-shadow: none; +} + +.pagination-links a:hover { + border-color: var(--mb-border-strong); + background: var(--mb-subtle); +} + +@media (forced-colors: active) { + .sidebar-content a[aria-current="page"] { + box-shadow: none; + outline: 1px solid Highlight; + } +} diff --git a/docs/src/styles/typography.css b/docs/src/styles/typography.css new file mode 100644 index 0000000..32b9ff7 --- /dev/null +++ b/docs/src/styles/typography.css @@ -0,0 +1,54 @@ +/* Mona Sans and JetBrains Mono, self-hosted Latin subsets from Fontsource 5.3.0 (SIL OFL, licenses in ../fonts). */ + +@font-face { + font-family: "Mona Sans Variable"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + font-stretch: 75% 125%; + src: url("../fonts/mona-sans-latin-standard-normal.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Mona Sans Variable"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + font-stretch: 75% 125%; + src: url("../fonts/mona-sans-latin-ext-standard-normal.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, + U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} + +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url("../fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url("../fonts/jetbrains-mono-latin-ext-wght-normal.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, + U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} + +:root { + --brand-font-sans: "Mona Sans Variable", "Mona Sans", system-ui, sans-serif; + --brand-font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace; +} diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/docs/wrangler.jsonc b/docs/wrangler.jsonc new file mode 100644 index 0000000..c0b3cb0 --- /dev/null +++ b/docs/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "mlbundle-docs", + "compatibility_date": "2026-10-19", + "workers_dev": false, + "preview_urls": false, + "assets": { + "directory": "./dist", + "html_handling": "force-trailing-slash", + "not_found_handling": "404-page", + }, + "routes": [{ "pattern": "docs.mlbundle.eshkra.dev", "custom_domain": true }], +} diff --git a/landing/.gitignore b/landing/.gitignore new file mode 100644 index 0000000..eb1c464 --- /dev/null +++ b/landing/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.astro/ +.wrangler/ diff --git a/landing/.prettierignore b/landing/.prettierignore new file mode 100644 index 0000000..50aa970 --- /dev/null +++ b/landing/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.astro/ +.wrangler/ +pnpm-lock.yaml +src/data/example.json diff --git a/landing/.prettierrc.json b/landing/.prettierrc.json new file mode 100644 index 0000000..3f6e0ab --- /dev/null +++ b/landing/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "plugins": ["prettier-plugin-astro"], + "printWidth": 100, + "tabWidth": 2, + "endOfLine": "lf", + "trailingComma": "all" +} diff --git a/landing/README.md b/landing/README.md new file mode 100644 index 0000000..eac1dda --- /dev/null +++ b/landing/README.md @@ -0,0 +1,46 @@ +# MLbundle landing + +The product site intended for , built with Astro. It shares the +eshkra palette, Mona Sans and JetBrains Mono fonts, and the original MLbundle mark from the +repository's `brand/mlbundle.png`. The page follows the visitor's light or dark system preference. + +## Local development + +Install once from the repository root, which owns the pnpm workspace and lockfile: + +```sh +vp install --frozen-lockfile +``` + +Then, from this directory, with Node.js 22.18+, 24.11+, or 26+: + +```sh +vp run dev +``` + +Open . In development, docs and playground links point to the sibling +dev servers on ports 4341 and 10021; builds link the intended `*.mlbundle.eshkra.dev` hosts. +`vp run build` checks Astro/TypeScript and produces the static site in `dist/`; `vp run preview` +serves that build. Use `vp run check:format` to check formatting or `vp run format` to apply it. + +Actions deploys the tested build to Cloudflare after all checks pass on `main`. +See [website deployment](../docs/deployment.md) for triggers and configuration. +Use these package scripts rather than a bare `vp build`, which runs Vite directly and cannot build +Astro. + +## The example is real output + +`examples/train.py` trains the model whose export and load the hero shows, from the `export(` +call on. `scripts/example.py` runs it in a temporary directory with the repository's `uv` +environment and stores the run in `src/data/example.json`: the printed prediction, the bundle's +members, sizes and hashes, its manifest and schema, and the CLI output of `inspect` and `verify`. +It then edits one claim in `manifest.json` (`max_depth` from `null` to `3`) and records how +`inspect` and `verify` treat that copy. Every version, size, member and terminal line on the page +comes from this file. The build does not run Python; regenerate the stored output after changing +the example or the library: + +```sh +vp run example +``` + +The bundle id, timestamp, environment and `model.skops` hash change on every run. diff --git a/landing/astro.config.ts b/landing/astro.config.ts new file mode 100644 index 0000000..fc96e66 --- /dev/null +++ b/landing/astro.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "astro/config"; + +export default defineConfig({ + site: "https://mlbundle.eshkra.dev", + trailingSlash: "always", + devToolbar: { enabled: false }, + vite: { + // The dev server must read the shared brand mark outside this package. + server: { + fs: { + allow: [ + fileURLToPath(new URL(".", import.meta.url)), + fileURLToPath(new URL("../brand", import.meta.url)), + ], + }, + }, + }, +}); diff --git a/landing/examples/train.py b/landing/examples/train.py new file mode 100644 index 0000000..ac1628d --- /dev/null +++ b/landing/examples/train.py @@ -0,0 +1,24 @@ +import pandas as pd +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import export, load + +training = pd.DataFrame( + [[0.0, 1.0, "cold"], [1.0, 0.0, "warm"]] * 2, + columns=["temperature", "humidity", "target"], +) +features = training.drop(columns="target") +model = DecisionTreeClassifier(random_state=7) +model.fit(features, training["target"]) + +export( + model, + "trained.mlbundle", + training_data=training, + target_names=["target"], +) + +bundle = load("trained.mlbundle") +record = {"temperature": 0.0, "humidity": 1.0} +prediction = bundle.predict_record(record)[0] +print(prediction.predicted_label, prediction.probabilities) diff --git a/landing/package.json b/landing/package.json new file mode 100644 index 0000000..2c42c60 --- /dev/null +++ b/landing/package.json @@ -0,0 +1,28 @@ +{ + "name": "mlbundle-landing", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vp exec astro dev --host 127.0.0.1 --port 4340", + "build": "vp run typecheck && vp exec astro build", + "typecheck": "vp exec astro check", + "example": "uv run --project .. python scripts/example.py", + "check:format": "vp exec prettier --check .", + "format": "vp exec prettier --write .", + "preview": "vp exec astro preview --host 127.0.0.1 --port 4340" + }, + "dependencies": { + "astro": "7.3.2", + "sharp": "0.35.5" + }, + "devDependencies": { + "@astrojs/check": "0.9.10", + "prettier": "3.9.10", + "prettier-plugin-astro": "1.1.1", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + } +} diff --git a/landing/public/_headers b/landing/public/_headers new file mode 100644 index 0000000..ca37c7b --- /dev/null +++ b/landing/public/_headers @@ -0,0 +1,3 @@ +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin diff --git a/landing/public/licenses/jetbrains-mono.txt b/landing/public/licenses/jetbrains-mono.txt new file mode 100644 index 0000000..8f7ed67 --- /dev/null +++ b/landing/public/licenses/jetbrains-mono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/landing/public/licenses/mona-sans.txt b/landing/public/licenses/mona-sans.txt new file mode 100644 index 0000000..fd6728b --- /dev/null +++ b/landing/public/licenses/mona-sans.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Mona Sans Project Authors (https://github.com/github/mona-sans), with Reserved Font Name "Mona" MonaSans-Italic[wdth,wght].ttf: Copyright 2022 The Mona Sans Project Authors (https://github.com/github/mona-sans), with Reserved Font Name "Mona" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/landing/public/robots.txt b/landing/public/robots.txt new file mode 100644 index 0000000..80b7cf1 --- /dev/null +++ b/landing/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://mlbundle.eshkra.dev/sitemap.xml diff --git a/landing/public/sitemap.xml b/landing/public/sitemap.xml new file mode 100644 index 0000000..9278900 --- /dev/null +++ b/landing/public/sitemap.xml @@ -0,0 +1,4 @@ + + + https://mlbundle.eshkra.dev/ + diff --git a/landing/ruff.toml b/landing/ruff.toml new file mode 100644 index 0000000..fa147f1 --- /dev/null +++ b/landing/ruff.toml @@ -0,0 +1,7 @@ +# The landing example and its capture script report to the console by design. +extend = "../pyproject.toml" + +[lint] +extend-ignore = [ + "T201", # print() output is what the page shows and what the script reports. +] diff --git a/landing/scripts/example.py b/landing/scripts/example.py new file mode 100644 index 0000000..27a9531 --- /dev/null +++ b/landing/scripts/example.py @@ -0,0 +1,90 @@ +"""Run examples/train.py and store everything the landing page shows about it. + +The page never hand-writes bundle output: members, hashes, manifest, schema, CLI +text and the tampered-bundle rejection all come from this run. Usage, from landing/: +uv run --project .. python scripts/example.py +""" + +import contextlib +import io +import json +import os +import runpy +import tempfile +import zipfile +from pathlib import Path + +import mlbundle +from mlbundle import inspect +from mlbundle.cli import main as cli + +LANDING = Path(__file__).resolve().parents[1] +SOURCE = LANDING / "examples" / "train.py" +OUTPUT = LANDING / "src" / "data" / "example.json" + + +def run_cli(*argv: str) -> dict[str, object]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli(list(argv)) + return { + "exit": code, + "stdout": out.getvalue().rstrip(), + "stderr": err.getvalue().rstrip(), + } + + +def tamper(path: Path, target: Path) -> None: + """Rewrite one model claim in manifest.json, keeping every other member intact.""" + with zipfile.ZipFile(path) as source, zipfile.ZipFile(target, "w") as out: + for info in source.infolist(): + data = source.read(info.filename) + if info.filename == "manifest.json": + manifest = json.loads(data) + manifest["model"]["hyperparameters"]["max_depth"] = 3 + data = json.dumps(manifest).encode() + out.writestr(info, data) + + +def main() -> None: + previous = Path.cwd() + with tempfile.TemporaryDirectory() as work: + os.chdir(work) + try: + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + runpy.run_path(str(SOURCE), run_name="__main__") + bundle = Path("trained.mlbundle") + view = inspect(bundle) + tamper(bundle, Path("edited.mlbundle")) + result = { + "version": mlbundle.__version__, + "code": SOURCE.read_text(encoding="utf-8"), + "printed": printed.getvalue().rstrip(), + "bundleBytes": bundle.stat().st_size, + "members": [ + { + "name": member.value, + "size": record.size if record else None, + "sha256": record.sha256 if record else None, + } + for member in view.members + for record in [view.manifest.integrity.get(member)] + ], + "manifest": view.manifest.model_dump(mode="json", by_alias=True), + "schema": view.schema, + "inspect": run_cli("inspect", "trained.mlbundle"), + "verify": run_cli("verify", "trained.mlbundle"), + "editedInspect": run_cli("inspect", "edited.mlbundle"), + "editedVerify": run_cli("verify", "edited.mlbundle"), + } + finally: + os.chdir(previous) + OUTPUT.write_text( + json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f"Wrote {OUTPUT.relative_to(LANDING)}") + + +if __name__ == "__main__": + main() diff --git a/landing/src/components/BundleCard.astro b/landing/src/components/BundleCard.astro new file mode 100644 index 0000000..02142bd --- /dev/null +++ b/landing/src/components/BundleCard.astro @@ -0,0 +1,36 @@ +--- +import { example, formatBytes } from "../data/example"; + +// What each member of the example bundle holds; names and sizes come from the stored run. +const roles: Record = { + "manifest.json": "What the model claims to be", + "model.skops": "The fitted model, in skops format", + "schema.json": "Inputs and outputs, inferred locally", + "structure.json": "The decision tree, for review", +}; +const [verdict] = example.verify.stdout.split(";"); +--- + +
+
+ trained.mlbundle + {formatBytes(example.bundleBytes)} +
+
    + {example.members.map(({ name, size }) => ( +
  • + {name} + {roles[name]} + {size !== null && {formatBytes(size)}} +
  • + ))} +
+
+ {" "} + mlbundle verify trained.mlbundle +
+ {verdict} +
+
diff --git a/landing/src/components/Formats.astro b/landing/src/components/Formats.astro new file mode 100644 index 0000000..00a71e6 --- /dev/null +++ b/landing/src/components/Formats.astro @@ -0,0 +1,54 @@ +--- +import { docs } from "../data/site"; + +// The four supported libraries and their native members, as in src/mlbundle/contract.py. +const libraries = [ + { + name: "scikit-learn", + member: "model.skops", + extra: "sklearn", + note: "skops, with types allowed by MLbundle", + }, + { name: "XGBoost", member: "model.ubj", extra: "xgboost", note: "Native UBJSON" }, + { name: "CatBoost", member: "model.cbm", extra: "catboost", note: "Native CBM" }, + { name: "ONNX", member: "model.onnx", extra: "onnx", note: "Your file, byte for byte" }, +]; +--- + +
+
+
+

Four libraries, each in its own format.

+

+ A closed adapter inside MLbundle loads each format. Nothing is imported from names written + in the bundle, and there is no pickle fallback. A model MLbundle cannot load safely is + rejected when you export it, on your machine, not after you share the file. +

+ + Supported models + +
+ + + + + + + + + + + {libraries.map(({ name, member, extra, note }) => ( + + + + + + ))} + +
Supported libraries, stored file and install extra
LibraryStored asExtra
{name} + {member} + {note} + {extra}
+
+
diff --git a/landing/src/components/Hero.astro b/landing/src/components/Hero.astro new file mode 100644 index 0000000..c76d0a2 --- /dev/null +++ b/landing/src/components/Hero.astro @@ -0,0 +1,93 @@ +--- +import { Image } from "astro:assets"; +import { Code } from "astro:components"; +import mark from "../../../brand/mlbundle.png"; +import BundleCard from "./BundleCard.astro"; +import Icon from "./Icon.astro"; +import { example, excerpt } from "../data/example"; +import { install, links } from "../data/site"; + +const themes = { light: "github-light-default", dark: "github-dark-default" } as const; +--- + +
+
+ +

+ Your trained model, in one verifiable file. +

+

+ MLbundle packs a scikit-learn, XGBoost, CatBoost or ONNX model with its manifest and input + schema. Loading recomputes what the file claims about the model from the model itself. No + pickle anywhere. +

+ +
+ + {install} + + +
+
+
+
+
+
+
+ train.py + excerpt +
+ +
+ {" "} + python train.py +
+ {example.printed} +
+
+ +
+
+
+ Real output of examples/train.py with MLbundle {example.version}. +
+
+
+ diff --git a/landing/src/components/Icon.astro b/landing/src/components/Icon.astro new file mode 100644 index 0000000..6d7414e --- /dev/null +++ b/landing/src/components/Icon.astro @@ -0,0 +1,45 @@ +--- +// Stroke icons follow Lucide's 24px grid (ISC licence); the GitHub mark is filled. +interface Props { + name: "arrow-up-right" | "github" | "copy" | "package"; + size?: number; +} +const { name, size = 16 } = Astro.props; +--- + + diff --git a/landing/src/components/Inside.astro b/landing/src/components/Inside.astro new file mode 100644 index 0000000..74787f3 --- /dev/null +++ b/landing/src/components/Inside.astro @@ -0,0 +1,47 @@ +--- +import { docs } from "../data/site"; + +// Three things every bundle carries; samples are the one optional addition. +const parts = [ + { + title: "The model, in its own format", + text: "Each library keeps its native serialization. The model is stored as it is, never converted and never pickled.", + }, + { + title: "A manifest of what it claims", + text: "Class, hyperparameters, feature names and order, class labels, the Python and package versions it was trained with, and a SHA-256 hash for each of the other files.", + }, + { + title: "A schema made on your machine", + text: "MLschema describes each input from the training DataFrame locally. The DataFrame itself is not written to the bundle.", + }, +]; +--- + +
+
+
+

One file, three parts.

+

+ A .mlbundle is a ZIP with a closed list of files. Anything else + inside it is rejected before the model is read. +

+
+
    + {parts.map(({ title, text }) => ( +
  • +

    {title}

    +

    {text}

    +
  • + ))} +
+

+ Models with readable internals, such as trees and linear coefficients, also carry that + structure for review, checked against the loaded model. Training rows are added only when you + ask for them with sample_rows=N. + + Format specification + +

+
+
diff --git a/landing/src/components/Schema.astro b/landing/src/components/Schema.astro new file mode 100644 index 0000000..7e9bfc4 --- /dev/null +++ b/landing/src/components/Schema.astro @@ -0,0 +1,49 @@ +--- +import { docs } from "../data/site"; + +const travels = [ + "Feature names, order and dtypes", + "Each input's kind and whether it may be missing", + "Categories and class labels", + "The fitted model, and what it learned", +]; +const stays = [ + "The training DataFrame and its rows", + "Ranges, means and counts", + "Example rows, unless you ask for them", +]; +--- + +
+
+
+

Training data stays on your machine.

+

+ Export infers the input schema where the data already is, and keeps only the description. + Want example rows in the bundle? Pass sample_rows=N: they are real + data, so they are never added by default. +

+ + Schema and samples + +
+
+
+

In the bundle

+
    + {travels.map((item) => ( +
  • {item}
  • + ))} +
+
+
+

Left behind

+
    + {stays.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+
diff --git a/landing/src/components/Start.astro b/landing/src/components/Start.astro new file mode 100644 index 0000000..794c5b8 --- /dev/null +++ b/landing/src/components/Start.astro @@ -0,0 +1,41 @@ +--- +import Icon from "./Icon.astro"; +import Wordmark from "./Wordmark.astro"; +import { docs, family, links } from "../data/site"; + +const resources = [ + { title: "Documentation", text: "Guides and the format", href: links.docs }, + { title: "Quick start", text: "Export and load a model", href: docs.quickStart }, + { title: "Playground", text: "Try it in the browser", href: links.playground }, + { title: "GitHub", text: "Source and issues", href: links.github }, + { title: "Installation", text: "Extras and checkouts", href: docs.install }, +]; +--- + +
+
+
+

Start with one model.

+

Export a model you already have, then load it the way your application will.

+
+ +
    + {family.map(({ name, stage, href }) => ( +
  1. + {stage} + + + +
  2. + ))} +
+
+
diff --git a/landing/src/components/Terminal.astro b/landing/src/components/Terminal.astro new file mode 100644 index 0000000..ea72691 --- /dev/null +++ b/landing/src/components/Terminal.astro @@ -0,0 +1,19 @@ +--- +// A stored CLI run: the command, its real output and exit code. +interface Props { + command: string; + output: string; + exit: number; +} +const { command, output, exit } = Astro.props; +const ok = exit === 0; +--- + +
+
+ {command.split(" ")[1]} + exit {exit} +
+
{command}
+{output}
+
diff --git a/landing/src/components/Verify.astro b/landing/src/components/Verify.astro new file mode 100644 index 0000000..44c1e3f --- /dev/null +++ b/landing/src/components/Verify.astro @@ -0,0 +1,47 @@ +--- +import Terminal from "./Terminal.astro"; +import { example } from "../data/example"; +import { docs } from "../data/site"; + +const firstLine = (text: string) => text.split("\n")[0]; +--- + +
+
+
+

Inspect reads it. Load checks it.

+

+ inspect validates the archive, manifest and hashes without + importing a model library. load also deserializes the model, + recomputes the claims derived from it, such as hyperparameters, counts, class labels and + tree structure, and rejects the file when they differ. +

+
+
+
+ + +
+
+ The example bundle with one edit: max_depth changed from{" "} + null to 3 in the manifest. +
+
+

+ Hashes catch corrupted files, not deliberate edits, and bundles are not signed. The training + environment, schema and samples are validated but not authenticated. Treat a bundle from + someone else as untrusted input. + + What MLbundle checks + +

+
+
diff --git a/landing/src/components/Wordmark.astro b/landing/src/components/Wordmark.astro new file mode 100644 index 0000000..22caa31 --- /dev/null +++ b/landing/src/components/Wordmark.astro @@ -0,0 +1,12 @@ +--- +// The eshkra lockup for every visible product name: "ML" wide and heavy, the rest light. +interface Props { + name: "mlbundle" | "mlschema" | "mlform" | "mlsuite"; +} +const { name } = Astro.props; +--- + + + ML + {name.slice(2)} + diff --git a/landing/src/data/example.json b/landing/src/data/example.json new file mode 100644 index 0000000..acc01e0 --- /dev/null +++ b/landing/src/data/example.json @@ -0,0 +1,180 @@ +{ + "version": "0.2.0", + "code": "import pandas as pd\nfrom sklearn.tree import DecisionTreeClassifier\n\nfrom mlbundle import export, load\n\ntraining = pd.DataFrame(\n [[0.0, 1.0, \"cold\"], [1.0, 0.0, \"warm\"]] * 2,\n columns=[\"temperature\", \"humidity\", \"target\"],\n)\nfeatures = training.drop(columns=\"target\")\nmodel = DecisionTreeClassifier(random_state=7)\nmodel.fit(features, training[\"target\"])\n\nexport(\n model,\n \"trained.mlbundle\",\n training_data=training,\n target_names=[\"target\"],\n)\n\nbundle = load(\"trained.mlbundle\")\nrecord = {\"temperature\": 0.0, \"humidity\": 1.0}\nprediction = bundle.predict_record(record)[0]\nprint(prediction.predicted_label, prediction.probabilities)\n", + "printed": "cold (1.0, 0.0)", + "bundleBytes": 9227, + "members": [ + { + "name": "manifest.json", + "size": null, + "sha256": null + }, + { + "name": "model.skops", + "size": 15011, + "sha256": "13257b547ae5a12c2bc5cea20afaf68d1a36d6073c8f77f1ae676979c40375d6" + }, + { + "name": "schema.json", + "size": 388, + "sha256": "e770ad53af881faf368c686de86b2b00f0a64a1444e241985643349bf7b0bba4" + }, + { + "name": "structure.json", + "size": 511, + "sha256": "8cf658be94b826ba4f81f1a78ed2c8e5107fce656d212dc888bd8621a9068baa" + } + ], + "manifest": { + "format_name": "mlbundle", + "format_version": "1", + "bundle_id": "a9e717a9-b071-4d23-bbd1-6deedc47e6b5", + "created_at": "2026-10-19T11:17:33.114088Z", + "writer_version": "0.2.0", + "model": { + "estimator_class": "DecisionTreeClassifier", + "kind": "classifier", + "hyperparameters": { + "ccp_alpha": 0.0, + "class_weight": null, + "criterion": "gini", + "max_depth": null, + "max_features": null, + "max_leaf_nodes": null, + "min_impurity_decrease": 0.0, + "min_samples_leaf": 1, + "min_samples_split": 2, + "min_weight_fraction_leaf": 0.0, + "monotonic_cst": null, + "random_state": 7, + "splitter": "best" + }, + "n_features": 2, + "n_classes": [ + 2 + ], + "n_outputs": 1, + "state_sha256": null, + "library": "sklearn", + "serialization": "skops", + "pipeline": null + }, + "features": { + "names": [ + "temperature", + "humidity" + ], + "dtypes": [ + "float64", + "float64" + ], + "model_dtypes": null, + "origin": "model" + }, + "target": { + "class_labels": [ + [ + "cold", + "warm" + ] + ], + "names": [ + "target" + ] + }, + "environment": { + "python_version": "3.14.7", + "python_implementation": "CPython", + "os": "Windows", + "os_version": "11", + "architecture": "AMD64", + "packages": { + "catboost": "1.2.10", + "mlbundle": "0.2.0", + "mlschema": "0.3.0", + "numpy": "2.5.4", + "onnx": "1.23.0", + "onnxruntime": "1.30.0", + "pandas": "3.0.6", + "scikit-learn": "1.9.1", + "scipy": "1.18.1", + "skops": "0.16.0", + "xgboost": "3.4.1" + }, + "onnx": null + }, + "integrity": { + "model.skops": { + "sha256": "13257b547ae5a12c2bc5cea20afaf68d1a36d6073c8f77f1ae676979c40375d6", + "size": 15011 + }, + "schema.json": { + "sha256": "e770ad53af881faf368c686de86b2b00f0a64a1444e241985643349bf7b0bba4", + "size": 388 + }, + "structure.json": { + "sha256": "8cf658be94b826ba4f81f1a78ed2c8e5107fce656d212dc888bd8621a9068baa", + "size": 511 + } + }, + "references": { + "schema": "schema.json", + "structure": "structure.json", + "samples": null + }, + "user_metadata": {} + }, + "schema": { + "fields": [ + { + "kind": "number", + "label": "temperature", + "mappedTo": "temperature", + "required": true, + "step": 0.1 + }, + { + "kind": "number", + "label": "humidity", + "mappedTo": "humidity", + "required": true, + "step": 0.1 + } + ], + "onehot_separator": "__", + "reports": [ + { + "kind": "classifier", + "label": "Predicted class", + "labels": [ + "cold", + "warm" + ], + "mappedTo": 0, + "showClassProbabilities": true + } + ], + "schema_version": "2", + "source": "training_data" + }, + "inspect": { + "exit": 0, + "stdout": "Metadata and integrity validated; model claims NOT verified.\nFormat: mlbundle 1\nBundle id: a9e717a9-b071-4d23-bbd1-6deedc47e6b5\nCreated: 2026-10-19T11:17:33.114088+00:00\nWriter: \"0.2.0\"\nModel: sklearn \"DecisionTreeClassifier\" (classifier)\nCounts: features=2, outputs=1, classes=[2]\nFeatures (model): [\"temperature\", \"humidity\"]\nClass labels (output-major): [[\"cold\", \"warm\"]]\nTarget names (output-major): [\"target\"]\nMembers:\n manifest.json: validated (no self-integrity record)\n model.skops: 15011 bytes sha256=13257b547ae5a12c2bc5cea20afaf68d1a36d6073c8f77f1ae676979c40375d6\n schema.json: 388 bytes sha256=e770ad53af881faf368c686de86b2b00f0a64a1444e241985643349bf7b0bba4\n structure.json: 511 bytes sha256=8cf658be94b826ba4f81f1a78ed2c8e5107fce656d212dc888bd8621a9068baa\nTraining: {\"python\": \"3.14.7\", \"implementation\": \"CPython\", \"os\": \"Windows\", \"os_version\": \"11\", \"architecture\": \"AMD64\"}\nPackages: {\"catboost\": \"1.2.10\", \"mlbundle\": \"0.2.0\", \"mlschema\": \"0.3.0\", \"numpy\": \"2.5.4\", \"onnx\": \"1.23.0\", \"onnxruntime\": \"1.30.0\", \"pandas\": \"3.0.6\", \"scikit-learn\": \"1.9.1\", \"scipy\": \"1.18.1\", \"skops\": \"0.16.0\", \"xgboost\": \"3.4.1\"}\nONNX environment: null\nPresent: schema=yes, structure=yes, samples=no\nUser metadata: {}", + "stderr": "" + }, + "verify": { + "exit": 0, + "stdout": "Valid bundle: sklearn \"DecisionTreeClassifier\"; verified format, integrity, schema/samples and native model claims/structure.", + "stderr": "" + }, + "editedInspect": { + "exit": 0, + "stdout": "Metadata and integrity validated; model claims NOT verified.\nFormat: mlbundle 1\nBundle id: a9e717a9-b071-4d23-bbd1-6deedc47e6b5\nCreated: 2026-10-19T11:17:33.114088+00:00\nWriter: \"0.2.0\"\nModel: sklearn \"DecisionTreeClassifier\" (classifier)\nCounts: features=2, outputs=1, classes=[2]\nFeatures (model): [\"temperature\", \"humidity\"]\nClass labels (output-major): [[\"cold\", \"warm\"]]\nTarget names (output-major): [\"target\"]\nMembers:\n manifest.json: validated (no self-integrity record)\n model.skops: 15011 bytes sha256=13257b547ae5a12c2bc5cea20afaf68d1a36d6073c8f77f1ae676979c40375d6\n schema.json: 388 bytes sha256=e770ad53af881faf368c686de86b2b00f0a64a1444e241985643349bf7b0bba4\n structure.json: 511 bytes sha256=8cf658be94b826ba4f81f1a78ed2c8e5107fce656d212dc888bd8621a9068baa\nTraining: {\"python\": \"3.14.7\", \"implementation\": \"CPython\", \"os\": \"Windows\", \"os_version\": \"11\", \"architecture\": \"AMD64\"}\nPackages: {\"catboost\": \"1.2.10\", \"mlbundle\": \"0.2.0\", \"mlschema\": \"0.3.0\", \"numpy\": \"2.5.4\", \"onnx\": \"1.23.0\", \"onnxruntime\": \"1.30.0\", \"pandas\": \"3.0.6\", \"scikit-learn\": \"1.9.1\", \"scipy\": \"1.18.1\", \"skops\": \"0.16.0\", \"xgboost\": \"3.4.1\"}\nONNX environment: null\nPresent: schema=yes, structure=yes, samples=no\nUser metadata: {}", + "stderr": "" + }, + "editedVerify": { + "exit": 1, + "stdout": "", + "stderr": "mlbundle: ClaimMismatchError: bundle rejected." + } +} diff --git a/landing/src/data/example.ts b/landing/src/data/example.ts new file mode 100644 index 0000000..de6fe14 --- /dev/null +++ b/landing/src/data/example.ts @@ -0,0 +1,32 @@ +// The stored run of examples/train.py; regenerate with `vp run example`. +import data from "./example.json"; + +export interface CliRun { + exit: number; + stdout: string; + stderr: string; +} + +export const example = data as typeof data & { + inspect: CliRun; + verify: CliRun; + editedInspect: CliRun; + editedVerify: CliRun; +}; + +// The hero shows the MLbundle part of the script: everything from the export call on. +const lines = example.code.trimEnd().split("\n"); +export const excerpt = lines + .slice(lines.findIndex((line) => line.startsWith("export("))) + .join("\n"); + +const units = ["B", "KB", "MB"]; +export const formatBytes = (bytes: number): string => { + let value = bytes; + let unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit += 1; + } + return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`; +}; diff --git a/landing/src/data/site.ts b/landing/src/data/site.ts new file mode 100644 index 0000000..d150d33 --- /dev/null +++ b/landing/src/data/site.ts @@ -0,0 +1,44 @@ +const github = "https://github.com/UlloaSP/mlbundle"; +const blob = `${github}/blob/main`; + +// Builds link the intended hosts; `vp run dev` links the sibling docs and playground dev servers. +const dev = import.meta.env.DEV; + +export const links = { + home: "https://mlbundle.eshkra.dev/", + eshkra: "https://eshkra.dev/", + docs: dev ? "http://127.0.0.1:4341/" : "https://docs.mlbundle.eshkra.dev/", + playground: dev ? "http://127.0.0.1:10021/" : "https://playground.mlbundle.eshkra.dev/", + github, + issues: `${github}/issues`, + license: `${blob}/LICENSE`, + security: `${blob}/SECURITY.md`, + format: `${blob}/docs/format.md`, + formatSchema: `${blob}/docs/format-schema.md`, + pypi: "https://pypi.org/project/mlbundle/", +}; + +// Docs pages the landing points into; the base already switches between dev and build. +export const docs = { + install: `${links.docs}getting-started/installation/`, + quickStart: `${links.docs}getting-started/quick-start/`, + inspectAndLoad: `${links.docs}guides/inspect-and-load/`, + trust: `${links.docs}guides/trust/`, + schema: `${links.docs}guides/schema-and-samples/`, + adapters: `${links.docs}adapters/overview/`, + format: `${links.docs}format/`, +}; + +export const description = + "Package a trained model, a verifiable manifest and a locally inferred schema in one .mlbundle file. Native formats for scikit-learn, XGBoost, CatBoost and ONNX, never pickle."; + +// The release install from the docs; checkout installs live on the docs installation page. +export const install = 'pip install "mlbundle[sklearn,schema]"'; + +// The eshkra path from a trained model to a reviewed prediction, as on eshkra.dev. +export const family = [ + { name: "mlbundle", stage: "Package", href: "https://mlbundle.eshkra.dev/" }, + { name: "mlschema", stage: "Describe", href: "https://mlschema.eshkra.dev/" }, + { name: "mlform", stage: "Render", href: "https://mlform.eshkra.dev/" }, + { name: "mlsuite", stage: "Run & review", href: "https://github.com/UlloaSP/mlsuite" }, +] as const; diff --git a/landing/src/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 b/landing/src/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..01769d9 Binary files /dev/null and b/landing/src/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 differ diff --git a/landing/src/fonts/jetbrains-mono-latin-wght-normal.woff2 b/landing/src/fonts/jetbrains-mono-latin-wght-normal.woff2 new file mode 100644 index 0000000..cd5102a Binary files /dev/null and b/landing/src/fonts/jetbrains-mono-latin-wght-normal.woff2 differ diff --git a/landing/src/fonts/mona-sans-latin-ext-standard-normal.woff2 b/landing/src/fonts/mona-sans-latin-ext-standard-normal.woff2 new file mode 100644 index 0000000..76057f5 Binary files /dev/null and b/landing/src/fonts/mona-sans-latin-ext-standard-normal.woff2 differ diff --git a/landing/src/fonts/mona-sans-latin-standard-normal.woff2 b/landing/src/fonts/mona-sans-latin-standard-normal.woff2 new file mode 100644 index 0000000..f6e67b1 Binary files /dev/null and b/landing/src/fonts/mona-sans-latin-standard-normal.woff2 differ diff --git a/landing/src/layouts/Base.astro b/landing/src/layouts/Base.astro new file mode 100644 index 0000000..9e084ab --- /dev/null +++ b/landing/src/layouts/Base.astro @@ -0,0 +1,138 @@ +--- +import { Image, getImage } from "astro:assets"; +import mark from "../../../brand/mlbundle.png"; +import Icon from "../components/Icon.astro"; +import Wordmark from "../components/Wordmark.astro"; +import { description as defaultDescription, links } from "../data/site"; +import "../styles/typography.css"; +import "../styles/base.css"; +import "../styles/chrome.css"; + +interface Props { + title?: string; + description?: string; + noindex?: boolean; +} +const { + title = "MLbundle · Trained models in one verifiable file", + description = defaultDescription, + noindex = false, +} = Astro.props; +const favicon = await getImage({ src: mark, width: 48, format: "png" }); +const touchIcon = await getImage({ src: mark, width: 180, format: "png" }); +const canonical = new URL(Astro.url.pathname, Astro.site); +const structuredData = { + "@context": "https://schema.org", + "@type": "SoftwareSourceCode", + name: "MLbundle", + description, + url: links.home, + codeRepository: links.github, + programmingLanguage: "Python", + runtimePlatform: "Python", + license: "https://opensource.org/license/mit", + author: { "@type": "Person", name: "Pablo Ulloa Santín", url: "https://github.com/UlloaSP" }, +}; +--- + + + + + + + + + + + + + + + {noindex && } + {title} + + + + + + + + + diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 0000000..4aa9ea0 --- /dev/null +++ b/playground/package.json @@ -0,0 +1,37 @@ +{ + "name": "mlbundle-playground", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vp dev --host 127.0.0.1 --port 10021 --strictPort", + "build": "vp build", + "preview": "vp preview --host 127.0.0.1 --port 10021 --strictPort", + "typecheck": "vp exec tsc -p tsconfig.json --noEmit", + "test:unit": "vp exec node --test tests/unit/*.test.ts", + "test:runtime": "vp exec node --test tests/runtime/*.test.ts", + "test:e2e": "vp run build && vp run test:e2e:built", + "test:e2e:built": "vp exec playwright test" + }, + "dependencies": { + "@codemirror/commands": "6.11.1", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-python": "6.2.1", + "@codemirror/language": "6.13.1", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.14", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/mona-sans": "5.3.0", + "@lezer/highlight": "1.2.5" + }, + "devDependencies": { + "@playwright/test": "1.64.1", + "@types/node": "26.6.5", + "pyodide": "314.0.7", + "typescript": "6.0.3", + "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + } +} diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts new file mode 100644 index 0000000..95e2cd4 --- /dev/null +++ b/playground/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Tests the production build on its own port, so a running dev server on 10021 is untouched. +export default defineConfig({ + testDir: "./tests/e2e", + // First boot downloads Python, scikit-learn and pandas from jsDelivr and PyPI. + timeout: 240_000, + expect: { timeout: 120_000 }, + fullyParallel: false, + workers: 1, + use: { baseURL: "http://127.0.0.1:10022", trace: "retain-on-failure" }, + webServer: { + command: "vp preview --host 127.0.0.1 --port 10022 --strictPort", + url: "http://127.0.0.1:10022", + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile-chromium", use: { ...devices["Pixel 7"] } }, + ], +}); diff --git a/playground/public/_headers b/playground/public/_headers new file mode 100644 index 0000000..8d11b46 --- /dev/null +++ b/playground/public/_headers @@ -0,0 +1,7 @@ +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), microphone=(), geolocation=() + +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/playground/src/assets/eshkra-mark.png b/playground/src/assets/eshkra-mark.png new file mode 100644 index 0000000..afb8fa6 Binary files /dev/null and b/playground/src/assets/eshkra-mark.png differ diff --git a/playground/src/env.d.ts b/playground/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/playground/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/playground/src/examples/classifier.py b/playground/src/examples/classifier.py new file mode 100644 index 0000000..db5a48b --- /dev/null +++ b/playground/src/examples/classifier.py @@ -0,0 +1,27 @@ +# A decision tree classifier exported with a schema inferred from its training frame. +import pandas as pd +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import export + +training = pd.DataFrame( + { + "temperature": [2.0, 31.0, 5.5, 28.0, 12.0, 25.5, 1.0, 34.0], + "humidity": [0.81, 0.32, 0.77, 0.41, 0.66, 0.35, 0.90, 0.28], + "weather": ["cold", "warm", "cold", "warm", "cold", "warm", "cold", "warm"], + } +) +features = training.drop(columns="weather") +model = DecisionTreeClassifier(max_depth=3, random_state=7) +model.fit(features, training["weather"]) + +# Writes classifier.mlbundle into this run's working directory. The playground then +# inspects it and verifies it with mlbundle.load before showing it on the right. +view = export( + model, + "classifier.mlbundle", + training_data=training, + target_names=["weather"], + metadata={"purpose": "playground example"}, +) +view.manifest.target.class_labels diff --git a/playground/src/examples/errors.py b/playground/src/examples/errors.py new file mode 100644 index 0000000..93d9fde --- /dev/null +++ b/playground/src/examples/errors.py @@ -0,0 +1,36 @@ +# mlbundle rejects unsupported models on the training machine, before writing anything. +from sklearn.ensemble import HistGradientBoostingClassifier +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import MLBundleError, export + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +labels = ["cold", "warm", "cold", "warm"] + + +class CustomTree(DecisionTreeClassifier): + """A user subclass: not on mlbundle's closed trust list.""" + + +attempts = { + "custom subclass": lambda: export( + CustomTree().fit(rows, labels), "custom.mlbundle" + ), + "HistGradientBoosting": lambda: export( + HistGradientBoostingClassifier(max_iter=3).fit(rows * 5, labels * 5), + "hist.mlbundle", + ), + "samples without a frame": lambda: export( + DecisionTreeClassifier().fit(rows, labels), "sampled.mlbundle", sample_rows=2 + ), +} +results = [] +for name, attempt in attempts.items(): + try: + attempt() + results.append({"case": name, "error": None}) + except MLBundleError as error: + results.append( + {"case": name, "error": type(error).__name__, "message": str(error)} + ) +results diff --git a/playground/src/examples/pipeline.py b/playground/src/examples/pipeline.py new file mode 100644 index 0000000..ca31cea --- /dev/null +++ b/playground/src/examples/pipeline.py @@ -0,0 +1,15 @@ +# An all-scikit-learn Pipeline. Its claims cover every step; version 1 records no +# structure for it, because the final step only sees transformed features. +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import SVR + +from mlbundle import export + +rows = [[0.0, 10.0], [1.0, 12.0], [2.0, 9.0], [3.0, 15.0], [4.0, 11.0]] +target = [1.2, 2.1, 2.9, 4.2, 5.1] +model = make_pipeline(StandardScaler(), SVR(C=3.0)).fit(rows, target) + +# Without a frame, names are declared and the schema comes from one default row. +view = export(model, "pipeline.mlbundle", feature_names=["dose_mg", "weight_kg"]) +{"source": view.schema["source"], "dtypes": view.manifest.features.dtypes} diff --git a/playground/src/examples/regressor.py b/playground/src/examples/regressor.py new file mode 100644 index 0000000..2307acf --- /dev/null +++ b/playground/src/examples/regressor.py @@ -0,0 +1,24 @@ +# A small random forest regressor. Its structure lists every fitted tree. +import numpy as np +import pandas as pd +from sklearn.ensemble import RandomForestRegressor + +from mlbundle import export + +rng = np.random.default_rng(3) +frame = pd.DataFrame( + { + "area_m2": rng.uniform(30, 160, 40).round(1), + "rooms": rng.integers(1, 6, 40), + "floor": rng.integers(0, 12, 40), + } +) +frame["price_k"] = ( + frame["area_m2"] * 2.4 + frame["rooms"] * 9 + rng.normal(0, 8, 40) +).round(1) + +model = RandomForestRegressor(n_estimators=3, max_depth=3, random_state=0) +model.fit(frame.drop(columns="price_k"), frame["price_k"]) +export(model, "regressor.mlbundle", training_data=frame, target_names=["price_k"]) + +model.feature_importances_.round(3).tolist() diff --git a/playground/src/examples/ruff.toml b/playground/src/examples/ruff.toml new file mode 100644 index 0000000..f543fe9 --- /dev/null +++ b/playground/src/examples/ruff.toml @@ -0,0 +1,9 @@ +# Playground examples are interactive scripts, not library code. +extend = "../../../pyproject.toml" + +[lint] +extend-ignore = [ + "B018", # The final bare expression is the result the playground displays. + "T201", # print() output is shown in the playground console. + "ERA001", # Commented code is a suggested edit for the visitor to try. +] diff --git a/playground/src/examples/samples.py b/playground/src/examples/samples.py new file mode 100644 index 0000000..5a6b01b --- /dev/null +++ b/playground/src/examples/samples.py @@ -0,0 +1,20 @@ +# plan__basic and plan__premium become one onehot-category field. +# Samples are real rows: they are included only because sample_rows is set. +import pandas as pd +from sklearn.linear_model import LogisticRegression + +from mlbundle import export + +features = pd.DataFrame( + { + "age": [34.0, 51.0, 27.0, 45.0, 62.0, 38.0], + "plan__basic": [1, 0, 1, 0, 0, 1], + "plan__premium": [0, 1, 0, 1, 1, 0], + "active": [True, False, True, True, False, True], + } +) +churned = ["no", "yes", "no", "no", "yes", "no"] +model = LogisticRegression(max_iter=500).fit(features, churned) + +view = export(model, "samples.mlbundle", training_data=features, sample_rows=3) +[field["kind"] for field in view.schema["fields"]] diff --git a/playground/src/icons.ts b/playground/src/icons.ts new file mode 100644 index 0000000..b7e079c --- /dev/null +++ b/playground/src/icons.ts @@ -0,0 +1,27 @@ +const paths = { + menu: '', + run: '', + stop: '', + reset: '', + restart: '', + copy: '', + check: '', + error: '', + loading: '', + ready: '', + download: '', + upload: '', + docs: '', + github: '', +} as const; + +export type Icon = keyof typeof paths; +export const icon = (name: Icon) => + ``; + +/** Icon-only control: the label is its accessible name and its hover/focus tooltip. */ +export const labelIcon = (element: HTMLElement, name: Icon, label: string, tooltip = label) => { + element.innerHTML = icon(name); + element.setAttribute("aria-label", label); + element.dataset.tooltip = tooltip; +}; diff --git a/playground/src/inspector/dom.ts b/playground/src/inspector/dom.ts new file mode 100644 index 0000000..326363e --- /dev/null +++ b/playground/src/inspector/dom.ts @@ -0,0 +1,87 @@ +type Child = Node | string | number | null | undefined | false; + +/** + * Builds elements from text only. Bundle contents are untrusted (uploads included), + * so nothing from a bundle is ever parsed as HTML. + */ +export const h = ( + tag: K, + attributes: Record = {}, + ...children: Child[] +): HTMLElementTagNameMap[K] => { + const element = document.createElement(tag); + for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value); + for (const child of children) { + if (child === null || child === undefined || child === false) continue; + element.append(typeof child === "object" ? child : String(child)); + } + return element; +}; + +/** Short display for any JSON value, including mlbundle's {"$float": "nan"} tags. */ +export const display = (value: unknown): string => { + if (value === null || value === undefined) return "—"; + if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(+value.toPrecision(6)); + if (typeof value === "string") return value; + if (typeof value === "object" && !Array.isArray(value) && "$float" in value) { + return String((value as { $float: unknown }).$float); + } + const text = JSON.stringify(value); + return text.length > 160 ? `${text.slice(0, 157)}…` : text; +}; + +export const formatBytes = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + const units = ["KiB", "MiB", "GiB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; +}; + +/** An accessible data table. Rows are capped; the caption reports what is hidden. */ +export const table = ( + caption: string, + headers: readonly string[], + rows: readonly (readonly unknown[])[], + limit = 200, +) => { + const shown = rows.slice(0, limit); + const note = rows.length > limit ? ` (first ${limit} of ${rows.length})` : ""; + return h( + "div", + { class: "inspector-table", tabindex: "0", role: "region", "aria-label": caption }, + h( + "table", + {}, + h("caption", {}, `${caption}${note}`), + h("thead", {}, h("tr", {}, ...headers.map((name) => h("th", { scope: "col" }, name)))), + h( + "tbody", + {}, + ...shown.map((row) => h("tr", {}, ...row.map((cell) => h("td", {}, display(cell))))), + ), + ), + ); +}; + +/** A term/description list for key facts. */ +export const facts = (label: string, entries: readonly (readonly [string, unknown])[]) => + h( + "dl", + { class: "inspector-facts", "aria-label": label }, + ...entries.flatMap(([term, value]) => [h("dt", {}, term), h("dd", {}, display(value))]), + ); + +export const section = (title: string, ...children: Child[]) => + h("section", { class: "inspector-section" }, h("h3", {}, title), ...children); + +export const note = (text: string) => h("p", { class: "inspector-note" }, text); + +export type JsonObject = { [key: string]: unknown }; +export const asObject = (value: unknown): JsonObject => + value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {}; +export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); diff --git a/playground/src/inspector/overview.ts b/playground/src/inspector/overview.ts new file mode 100644 index 0000000..d2015ac --- /dev/null +++ b/playground/src/inspector/overview.ts @@ -0,0 +1,83 @@ +import type { BundleReport } from "../runtime/protocol.ts"; +import { asArray, asObject, display, facts, formatBytes, h, note, section, table } from "./dom.ts"; + +const STATUS: Record = { + verified: { + title: "Verified with mlbundle.load", + detail: + "Inspected, then loaded through the closed sklearn adapter: model claims and structure were recomputed and matched.", + }, + inspected: { + title: "Inspected only", + detail: + "mlbundle.inspect validated the archive, limits, manifest, hashes, schema and samples. The model was not deserialized, so model claims and structure are not verified.", + }, + unverified: { + title: "Rejected by mlbundle.load", + detail: "The metadata passed inspection, but loading and recomputing the model claims failed.", + }, + rejected: { + title: "Rejected by mlbundle.inspect", + detail: "mlbundle refused this file before reading its metadata.", + }, +}; + +export const statusBanner = (bundle: BundleReport) => { + const { title, detail } = STATUS[bundle.status]; + return h( + "div", + { class: "inspector-status", "data-status": bundle.status, role: "note" }, + h("strong", {}, title), + h("span", {}, detail), + bundle.error ? h("code", { class: "inspector-error" }, bundle.error) : null, + ); +}; + +export const overview = (bundle: BundleReport) => { + const manifest = asObject(bundle.manifest); + if (!bundle.manifest) { + return [facts("File", [["File", bundle.fileName], ["Size", formatBytes(bundle.sizeBytes)]])]; + } + const model = asObject(manifest.model); + const features = asObject(manifest.features); + const target = asObject(manifest.target); + const environment = asObject(manifest.environment); + const names = asArray(features.names); + const dtypes = asArray(features.dtypes); + const labels = asArray(target.class_labels); + const targetNames = asArray(target.names); + const metadata = asObject(manifest.user_metadata); + return [ + facts("Model", [ + ["Estimator", model.estimator_class], + ["Kind", model.kind], + ["Library", model.library], + ["Serialization", model.serialization], + ["Features", model.n_features], + ["Outputs", model.n_outputs], + ["File", `${bundle.fileName} · ${formatBytes(bundle.sizeBytes)}`], + ["Format", `${display(manifest.format_name)} ${display(manifest.format_version)}`], + ["Writer", manifest.writer_version], + ["Created", manifest.created_at], + ]), + section( + "Features", + note(`Origin: ${display(features.origin)}. Training dtypes are declarations; null means none were recorded.`), + table("Features in model order", ["#", "Name", "Training dtype"], names.map((name, index) => [index, name, dtypes[index] ?? null])), + ), + section( + "Targets", + labels.length + ? table("Class labels per output", ["Output", "Name", "Labels"], labels.map((output, index) => [index, targetNames[index] ?? null, asArray(output).map(display).join(", ")])) + : note(targetNames.length ? `Regression targets: ${targetNames.map(display).join(", ")}` : "Regression output; no target names were declared."), + ), + section( + "Training environment", + note(`Python ${display(environment.python_version)} on ${display(environment.os)} ${display(environment.os_version)} (${display(environment.architecture)}). Declared by the writer, not authenticated.`), + table("Package versions", ["Package", "Version"], Object.entries(asObject(environment.packages))), + ), + Object.keys(metadata).length + ? section("User metadata", table("Author metadata", ["Key", "Value"], Object.entries(metadata))) + : null, + ]; +}; diff --git a/playground/src/inspector/panel.ts b/playground/src/inspector/panel.ts new file mode 100644 index 0000000..42f2b7f --- /dev/null +++ b/playground/src/inspector/panel.ts @@ -0,0 +1,151 @@ +import { labelIcon } from "../icons.ts"; +import type { BundleReport } from "../runtime/protocol.ts"; +import { createJsonViewer } from "../workspace/viewer.ts"; +import { formatBytes, h, note } from "./dom.ts"; +import { overview, statusBanner } from "./overview.ts"; +import { membersView, samplesView, schemaView } from "./schema.ts"; +import { structureView } from "./structure.ts"; + +export const TABS = [ + ["overview", "Overview"], + ["manifest", "Manifest"], + ["schema", "Schema"], + ["structure", "Structure"], + ["members", "Members"], + ["samples", "Samples"], + ["console", "Console"], +] as const; +export type TabId = (typeof TABS)[number][0]; + +const RAW_LIMIT = 200_000; +const STATUS_LABEL: Record = { + verified: "Verified", + inspected: "Inspected only", + unverified: "Load failed", + rejected: "Rejected", +}; + +const rawJson = (title: string, value: unknown) => { + const text = JSON.stringify(value, null, 2); + const clipped = text.length > RAW_LIMIT ? `${text.slice(0, RAW_LIMIT)}\n… [${text.length - RAW_LIMIT} more characters]` : text; + return h("details", { class: "inspector-raw" }, h("summary", {}, title), h("pre", { tabindex: "0" }, clipped)); +}; + +/** + * Bundle inspector: a bundle picker, a download action and one tab per view. + * Tabs follow the ARIA tabs pattern: arrow keys, Home and End move between them. + */ +export const createInspector = (host: HTMLElement, consoleView: HTMLElement) => { + const bar = h("div", { class: "inspector-bar" }); + const picker = h("select", { "aria-label": "Bundle" }); + const title = h("span", { class: "inspector-title" }); + const chip = h("span", { class: "inspector-chip" }); + const download = h("button", { type: "button", class: "icon-button" }); + labelIcon(download, "download", "Download .mlbundle"); + bar.append(h("label", { class: "code-select inspector-picker" }, picker), title, chip, download); + const tablist = h("div", { class: "inspector-tabs", role: "tablist", "aria-label": "Bundle views" }); + const panel = h("div", { class: "inspector-panel", role: "tabpanel", tabindex: "0", id: "inspector-panel" }); + const manifestHost = h("div", { class: "output-json" }); + const manifestViewer = createJsonViewer(manifestHost, "Manifest JSON"); + host.append(bar, tablist, panel); + + let bundles: BundleReport[] = []; + let selected = 0; + let active: TabId = "overview"; + let empty = "Run the code to export a bundle, or open a .mlbundle file to inspect it."; + const tabs = TABS.map(([id, label]) => { + const tab = h("button", { type: "button", role: "tab", id: `tab-${id}`, "aria-controls": "inspector-panel" }, label); + tab.addEventListener("click", () => show(id)); + return tab; + }); + tablist.append(...tabs); + tablist.addEventListener("keydown", (event) => { + const index = TABS.findIndex(([id]) => id === active); + const next = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: TABS.length - 1 }[event.key]; + if (next === undefined) return; + event.preventDefault(); + const target = (next + TABS.length) % TABS.length; + show(TABS[target]![0]); + tabs[target]!.focus(); + }); + + const content = (bundle: BundleReport | undefined): (Node | null)[] => { + if (active === "console") return [consoleView]; + if (!bundle) return [note(empty)]; + if (active === "manifest") { + if (!bundle.manifest) return [statusBanner(bundle)]; + manifestViewer.show(JSON.stringify(bundle.manifest, null, 2)); + return [manifestHost]; + } + if (!bundle.manifest) return [statusBanner(bundle)]; + switch (active) { + case "overview": + return [statusBanner(bundle), ...overview(bundle)]; + case "schema": + return [...schemaView(bundle), bundle.schema ? rawJson("schema.json", bundle.schema) : null]; + case "structure": + return [...structureView(bundle), bundle.structure ? rawJson("structure.json", bundle.structure) : null]; + case "members": + return membersView(bundle); + default: + return samplesView(bundle); + } + }; + + const render = () => { + const bundle = bundles[selected]; + for (const [index, tab] of tabs.entries()) { + const current = TABS[index]![0] === active; + tab.setAttribute("aria-selected", String(current)); + tab.tabIndex = current ? 0 : -1; + } + panel.setAttribute("aria-labelledby", `tab-${active}`); + panel.classList.toggle("is-manifest", active === "manifest" && Boolean(bundle?.manifest)); + panel.replaceChildren(...content(bundle).filter((item): item is Node => item !== null)); + panel.scrollTop = 0; + bar.hidden = !bundle; + if (!bundle) return; + picker.parentElement!.hidden = bundles.length < 2; + title.textContent = `${bundle.fileName} · ${formatBytes(bundle.sizeBytes)}`; + chip.textContent = STATUS_LABEL[bundle.status]; + chip.dataset.status = bundle.status; + download.hidden = !bundle.bytes; + }; + const show = (id: TabId) => { + active = id; + render(); + }; + + download.addEventListener("click", () => { + const bundle = bundles[selected]; + if (!bundle?.bytes) return; + const url = URL.createObjectURL(new Blob([bundle.bytes as BlobPart], { type: "application/zip" })); + const link = h("a", { href: url, download: bundle.fileName }); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }); + picker.addEventListener("change", () => { + selected = Number(picker.value); + render(); + }); + + render(); + return { + get active() { + return active; + }, + show, + /** Replace the shown bundles; `message` explains an empty list. */ + setBundles(next: BundleReport[], message: string) { + bundles = next; + selected = 0; + empty = message; + picker.replaceChildren(...next.map((bundle, index) => h("option", { value: String(index) }, bundle.fileName))); + render(); + }, + }; +}; + +export type Inspector = ReturnType; diff --git a/playground/src/inspector/schema.ts b/playground/src/inspector/schema.ts new file mode 100644 index 0000000..5ccd90e --- /dev/null +++ b/playground/src/inspector/schema.ts @@ -0,0 +1,48 @@ +import type { BundleReport } from "../runtime/protocol.ts"; +import { asArray, asObject, display, h, note, section, table, type JsonObject } from "./dom.ts"; + +const detail = (field: JsonObject) => { + const options = asArray(field.options).map(asObject); + if (field.kind === "onehot-category") return options.map((option) => `${display(option.label)} → ${display(option.mappedTo)}`).join(", "); + const parts: string[] = []; + for (const key of ["step", "min", "max", "options", "categories", "dtype"]) { + if (key in field) parts.push(`${key}: ${display(field[key])}`); + } + const columns = asArray(field.columns).map(asObject); + if (columns.length) parts.push(`columns: ${columns.map((column) => `${display(column.id)} (${display(column.kind)})`).join(", ")}`); + return parts.join(" · "); +}; + +export const schemaView = (bundle: BundleReport) => { + if (!bundle.schema) return [note("This bundle has no readable schema.")]; + const schema = bundle.schema; + const fields = asArray(schema.fields).map(asObject); + const reports = asArray(schema.reports).map(asObject); + return [ + note(`Schema version ${display(schema.schema_version)}, inferred locally by mlschema from ${schema.source === "default" ? "one default row of 1.0 values" : "the training frame"}. One-hot separator: "${display(schema.onehot_separator)}".`), + section( + "Fields", + table("Input fields", ["Label", "Kind", "Mapped to", "Required", "Details"], fields.map((field) => [field.label, field.kind, field.mappedTo ?? "options", field.required, detail(field)])), + ), + section( + "Reports", + table("Outputs", ["Label", "Kind", "Mapped to", "Labels"], reports.map((report) => [report.label, report.kind, report.mappedTo, asArray(report.labels).map(display).join(", ") || null])), + ), + ]; +}; + +export const membersView = (bundle: BundleReport) => [ + note("Members in archive order. The manifest records the SHA-256 and size of every other member; mlbundle checked them. Hashes detect corruption, not deliberate tampering."), + table("Archive members", ["Member", "Size (bytes)", "SHA-256"], (bundle.members ?? []).map((member) => [member.name, member.size ?? "manifest", member.sha256 ?? "—"])), +]; + +export const samplesView = (bundle: BundleReport) => { + if (!bundle.samples) { + return [note("No samples. mlbundle includes sample rows only when export is called with sample_rows=N, because they are real training data.")]; + } + const names = asArray(asObject(bundle.manifest?.features).names).map(String); + return [ + h("p", { class: "inspector-note" }, `${bundle.samples.length} explicitly requested feature rows, without target or index.`), + table("Sample rows", ["#", ...names], bundle.samples.map((row, index) => [index, ...names.map((name) => row[name])])), + ]; +}; diff --git a/playground/src/inspector/structure.ts b/playground/src/inspector/structure.ts new file mode 100644 index 0000000..f0ed106 --- /dev/null +++ b/playground/src/inspector/structure.ts @@ -0,0 +1,108 @@ +import type { BundleReport } from "../runtime/protocol.ts"; +import { asArray, asObject, display, h, note, section, type JsonObject } from "./dom.ts"; + +const NODE_LIMIT = 511; +const numbers = (value: unknown) => asArray(value).map((item) => (typeof item === "number" ? item : Number.NaN)); + +/** Relative bars for importances or coefficients, as a table so values stay readable. */ +const bars = (caption: string, names: readonly string[], values: readonly number[]) => { + const peak = Math.max(...values.map((value) => Math.abs(value)).filter(Number.isFinite), 0) || 1; + return h( + "div", + { class: "inspector-table", tabindex: "0", role: "region", "aria-label": caption }, + h( + "table", + { class: "inspector-bars" }, + h("caption", {}, caption), + h("thead", {}, h("tr", {}, h("th", { scope: "col" }, "Feature"), h("th", { scope: "col" }, "Value"), h("th", { scope: "col" }, h("span", { class: "visually-hidden" }, "Relative size")))), + h( + "tbody", + {}, + ...values.map((value, index) => { + const width = Number.isFinite(value) ? (Math.abs(value) / peak) * 100 : 0; + return h( + "tr", + {}, + h("td", {}, names[index] ?? `#${index}`), + h("td", {}, display(value)), + h("td", { "aria-hidden": "true" }, h("span", { class: "inspector-bar-fill", "data-sign": value < 0 ? "negative" : "positive", style: `width: ${width.toFixed(1)}%` })), + ); + }), + ), + ), + ); +}; + +/** One sklearn tree as an expandable outline, read straight from the native arrays. */ +export const treeOutline = (tree: JsonObject, names: readonly string[]): HTMLElement[] => { + const left = numbers(tree.children_left); + const right = numbers(tree.children_right); + const feature = numbers(tree.feature); + const threshold = asArray(tree.threshold); + const value = asArray(tree.value); + const samples = asArray(tree.n_node_samples); + let rendered = 0; + const node = (index: number, depth: number, branch: string): HTMLElement => { + rendered += 1; + const leaf = left[index] === -1 || left[index] === undefined; + const counts = `samples ${display(samples[index])} · value ${display(value[index])}`; + if (leaf || rendered >= NODE_LIMIT) { + return h("li", { class: "tree-leaf" }, h("span", { class: "tree-branch" }, branch), ` ${leaf ? "leaf" : "…"} · ${counts}`); + } + const name = names[feature[index]!] ?? `feature ${display(feature[index])}`; + const summary = h("summary", {}, h("span", { class: "tree-branch" }, branch), " ", h("strong", {}, name), ` ≤ ${display(threshold[index])}`, h("span", { class: "tree-meta" }, ` · ${counts}`)); + const children = h("ul", {}, node(left[index]!, depth + 1, "true"), node(right[index]!, depth + 1, "false")); + const open = depth < 3 ? { open: "" } : {}; + return h("li", {}, h("details", open, summary, children)); + }; + const root = h("ul", { class: "tree-outline", "aria-label": "Decision tree" }, node(0, 0, "root")); + const total = Number(tree.node_count); + return [ + note(`${display(tree.node_count)} nodes, depth ${display(tree.max_depth)}. Splits send rows with feature ≤ threshold to "true". value is sklearn's native node value, shown as stored.`), + root, + rendered < total ? note(`Showing the first ${rendered} of ${total} nodes; the raw JSON below has every node.`) : null, + ].filter((item) => item !== null); +}; + +const ensemble = (structure: JsonObject, names: readonly string[]) => { + const trees = asArray(structure.trees).map(asObject); + const host = h("div", { class: "tree-host" }); + const select = h("select", { "aria-label": "Tree" }, ...trees.map((_, index) => h("option", { value: String(index) }, `Tree ${index + 1} of ${trees.length}`))); + const show = () => host.replaceChildren(...treeOutline(trees[Number(select.value)] ?? {}, names)); + select.addEventListener("change", show); + show(); + return [note(`Estimator shape ${display(structure.estimator_shape)}.`), h("label", { class: "code-select" }, select), host]; +}; + +const linear = (structure: JsonObject, names: readonly string[]) => { + const coef = asArray(structure.coef); + const rows = Array.isArray(coef[0]) ? coef.map(numbers) : [numbers(coef)]; + return [ + note(`Intercept: ${display(structure.intercept)}. Coefficients apply to the model's input features, in order.`), + ...rows.map((row, index) => bars(rows.length > 1 ? `Coefficients, row ${index}` : "Coefficients", names, row)), + ]; +}; + +export const structureView = (bundle: BundleReport) => { + const structure = bundle.structure; + if (!structure) { + if (bundle.status === "inspected") { + return [note("Uploaded files are inspected without deserializing the model, so the playground does not show their structure: structure is only trustworthy once mlbundle.load has recomputed it from the model.")]; + } + if (bundle.status !== "verified") return [note("No verified structure: mlbundle did not load this bundle.")]; + return [note("This model has no structure in format version 1 (for example an all-scikit-learn Pipeline or an MLP). Claims still cover the whole model.")]; + } + const names = asArray(asObject(bundle.manifest?.features).names).map(String); + const family = String(structure.family); + const importances = asArray(structure.feature_importances); + const body = + family === "decision_tree" ? treeOutline(asObject(structure.tree), names) + : family === "tree_ensemble" ? ensemble(structure, names) + : family === "linear" ? linear(structure, names) + : []; + return [ + note(`Family: ${family}. Recomputed by mlbundle.load from the loaded model and compared with structure.json.`), + ...body, + importances.length ? section("Feature importances", bars("Feature importances", names, numbers(importances))) : null, + ]; +}; diff --git a/playground/src/links.ts b/playground/src/links.ts new file mode 100644 index 0000000..f1f5e92 --- /dev/null +++ b/playground/src/links.ts @@ -0,0 +1,9 @@ +// Development links point at the local landing and docs servers, as the landing does. +const dev = import.meta.env.DEV; + +export const LINKS = { + home: dev ? "http://127.0.0.1:4340/" : "https://mlbundle.eshkra.dev/", + docs: dev ? "http://127.0.0.1:4341/" : "https://docs.mlbundle.eshkra.dev/", + github: "https://github.com/UlloaSP/mlbundle", + eshkra: "https://eshkra.dev/", +} as const; diff --git a/playground/src/main.ts b/playground/src/main.ts new file mode 100644 index 0000000..e025d8d --- /dev/null +++ b/playground/src/main.ts @@ -0,0 +1,30 @@ +import "@fontsource-variable/mona-sans/standard.css"; +import "@fontsource-variable/jetbrains-mono/wght.css"; +import "./styles/index.css"; +import mark from "../../brand/mlbundle.png?url"; +import { bindMenu } from "./menu.ts"; +import { applyDesign, browserStorage, readSettings, saveSettings } from "./settings.ts"; +import { createShell } from "./shell.ts"; +import { mountViewport } from "./viewport.ts"; +import { mountWorkspace } from "./workspace/mount.ts"; + +document.querySelector('link[rel="icon"]')!.href = mark; +const storage = browserStorage(); +const settings = readSettings(storage); +applyDesign(document.documentElement, settings); +const root = document.querySelector("#root")!; +if (new URLSearchParams(location.search).has("editor")) { + // The editor frame: the page around it owns the header, the menu and the settings. + root.className = "editor-page"; + mountWorkspace(root, storage); +} else { + const shell = createShell(mark); + root.replaceWith(shell); + const updateViewport = mountViewport(shell.querySelector(".app-stage")!, settings); + bindMenu(shell, settings, (next) => { + applyDesign(document.documentElement, next); + updateViewport(next); + // Unavailable storage keeps the choice for this visit only. + saveSettings(storage, next); + }); +} diff --git a/playground/src/menu.ts b/playground/src/menu.ts new file mode 100644 index 0000000..ae5e131 --- /dev/null +++ b/playground/src/menu.ts @@ -0,0 +1,102 @@ +import { icon } from "./icons.ts"; +import { + APPEARANCES, + STYLES, + THEMES, + VIEWPORTS, + SIZE_LIMITS, + validSize, + type Settings, +} from "./settings.ts"; + +const options = (items: readonly { id: string; label: string }[]) => + items.map(({ id, label }) => ``).join(""); +const sizeInput = (key: "width" | "height", label: string) => + ``; + +/** Floating options menu, following MLForm's playground menu. Editor is the only workspace. */ +export const MENU = ` +
+ + +
`; + +export const bindMenu = ( + shell: HTMLElement, + initial: Settings, + change: (settings: Settings) => void, +) => { + let settings = initial; + const menu = shell.querySelector('[data-role="menu"]')!; + const button = menu.querySelector(".app-menu-button")!; + const panel = menu.querySelector(".app-menu-panel")!; + const editor = menu.querySelector('[data-role="editor-option"]')!; + const custom = menu.querySelector('[data-role="custom-size"]')!; + const fields = [...menu.querySelectorAll("[data-setting]")]; + + const isOpen = () => menu.classList.contains("is-open"); + const setOpen = (open: boolean, restoreFocus = false) => { + menu.classList.toggle("is-open", open); + button.setAttribute("aria-expanded", String(open)); + panel.hidden = !open; + if (open) editor.focus(); + else if (restoreFocus) button.focus(); + }; + const render = () => { + for (const field of fields) field.value = String(settings[field.dataset.setting as keyof Settings]); + custom.hidden = settings.viewport !== "custom"; + }; + const update = (field: HTMLInputElement | HTMLSelectElement) => { + const key = field.dataset.setting as keyof Settings; + const value = key === "width" || key === "height" ? Number(field.value) : field.value; + // Out-of-range sizes restore the last valid value instead of resizing. + if (typeof value === "number" && !validSize(value)) return render(); + settings = { ...settings, [key]: value }; + render(); + change(settings); + }; + + for (const field of fields) field.addEventListener("change", () => update(field)); + button.addEventListener("click", () => setOpen(!isOpen())); + button.addEventListener("keydown", (event) => { + if (event.key !== "ArrowDown") return; + event.preventDefault(); + setOpen(true); + }); + // Editor is already showing: choosing it only closes the menu. + editor.addEventListener("click", (event) => setOpen(false, event.detail === 0)); + document.addEventListener("click", (event) => { + if (isOpen() && event.target instanceof Node && !menu.contains(event.target)) setOpen(false); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && isOpen()) setOpen(false, true); + }); + menu.addEventListener("focusout", (event) => { + if (event.relatedTarget instanceof Node && !menu.contains(event.relatedTarget)) setOpen(false); + }); + // Clicks inside the editor frame never reach this document; the frame takes focus instead. + window.addEventListener("blur", () => { + if (document.activeElement instanceof HTMLIFrameElement) setOpen(false); + }); + render(); +}; diff --git a/playground/src/runtime/client.ts b/playground/src/runtime/client.ts new file mode 100644 index 0000000..f5d488a --- /dev/null +++ b/playground/src/runtime/client.ts @@ -0,0 +1,179 @@ +import type { FromWorker, OutputStream, RunReport, RuntimeVersions, ToWorker } from "./protocol.ts"; +import { BOOT_TIMEOUT_MS, RUN_TIMEOUT_MS } from "./versions.ts"; + +export interface WorkerLike { + postMessage(message: ToWorker, transfer?: Transferable[]): void; + terminate(): void; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; +} + +export type RuntimeState = + | { status: "booting"; message: string } + | { status: "ready"; versions: RuntimeVersions } + | { status: "running"; versions: RuntimeVersions } + | { status: "failed"; message: string }; + +export type RunResult = + | { kind: "finished"; report: RunReport; durationMs: number } + | { kind: "timeout"; limitMs: number } + | { kind: "cancelled" } + | { kind: "failed"; message: string } + | { kind: "unavailable" }; + +export interface RuntimeListener { + onState(state: RuntimeState): void; + onStream(stream: OutputStream, text: string): void; +} + +export interface RuntimeLimits { + bootMs: number; + runMs: number; +} + +interface PendingRun { + id: number; + timer: ReturnType; + resolve(result: RunResult): void; +} + +const describe = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +/** + * Owns the Python worker. A run that times out or is cancelled terminates the + * worker, which is the only way to stop arbitrary Python, and boots a fresh one. + * A worker failure ends any pending run at once and leaves the runtime failed + * until `restart()`. + */ +export const createPythonRuntime = ( + createWorker: () => WorkerLike, + listener: RuntimeListener, + limits: RuntimeLimits = { bootMs: BOOT_TIMEOUT_MS, runMs: RUN_TIMEOUT_MS }, +) => { + let worker: WorkerLike | undefined; + let state: RuntimeState = { status: "booting", message: "Starting Python…" }; + let pending: PendingRun | undefined; + let bootTimer: ReturnType | undefined; + let nextId = 1; + + const setState = (next: RuntimeState) => { + state = next; + listener.onState(next); + }; + const settle = (result: RunResult) => { + if (!pending) return; + clearTimeout(pending.timer); + const { resolve } = pending; + pending = undefined; + if (result.kind === "finished" && state.status === "running") { + setState({ status: "ready", versions: state.versions }); + } + resolve(result); + }; + const stopWorker = () => { + clearTimeout(bootTimer); + worker?.terminate(); + worker = undefined; + }; + const fail = (reason: string) => { + const message = `${state.status === "booting" ? "Python could not start" : "Python stopped unexpectedly"}: ${reason}`; + stopWorker(); + settle({ kind: "failed", message }); + setState({ status: "failed", message }); + }; + + const handle = (message: FromWorker) => { + switch (message.type) { + case "progress": + if (state.status === "booting") setState({ status: "booting", message: message.message }); + return; + case "ready": + clearTimeout(bootTimer); + setState({ status: "ready", versions: message.versions }); + return; + case "boot-error": + fail(message.message); + return; + case "stream": + if (message.id === pending?.id) listener.onStream(message.stream, message.text); + return; + case "done": + if (message.id === pending?.id) { + settle({ kind: "finished", report: message.report, durationMs: message.durationMs }); + } + } + }; + + const start = () => { + stopWorker(); + setState({ status: "booting", message: "Starting Python…" }); + let current: WorkerLike; + try { + current = createWorker(); + } catch (error) { + fail(describe(error)); + return; + } + worker = current; + current.onmessage = ({ data }) => { + if (worker === current) handle(data); + }; + current.onerror = (event) => { + if (worker !== current) return; + event.preventDefault(); + fail(event.message || "the Python worker crashed."); + }; + bootTimer = setTimeout( + () => fail(`no response within ${limits.bootMs / 1000} seconds.`), + limits.bootMs, + ); + }; + + const submit = (message: ToWorker, transfer: Transferable[] = []): Promise => { + if (state.status !== "ready" || !worker) return Promise.resolve({ kind: "unavailable" }); + const target = worker; + const result = new Promise((resolve) => { + const timer = setTimeout(() => { + settle({ kind: "timeout", limitMs: limits.runMs }); + start(); + }, limits.runMs); + pending = { id: message.id, timer, resolve }; + }); + setState({ status: "running", versions: state.versions }); + try { + target.postMessage(message, transfer); + } catch (error) { + fail(describe(error)); + } + return result; + }; + const run = (code: string) => submit({ type: "run", id: nextId++, code }); + /** Metadata-only inspection of a visitor's file; the buffer is transferred. */ + const inspect = (name: string, data: ArrayBuffer) => + submit({ type: "inspect", id: nextId++, name, data }, [data]); + + const restart = () => { + settle({ kind: "cancelled" }); + start(); + }; + + start(); + return { + run, + inspect, + restart, + /** Stops the current run, if any, by restarting Python. */ + cancel() { + if (pending) restart(); + }, + get state() { + return state; + }, + dispose() { + settle({ kind: "cancelled" }); + stopWorker(); + }, + }; +}; + +export type PythonRuntime = ReturnType; diff --git a/playground/src/runtime/harness.py b/playground/src/runtime/harness.py new file mode 100644 index 0000000..5d6cbc8 --- /dev/null +++ b/playground/src/runtime/harness.py @@ -0,0 +1,174 @@ +"""Execution harness for the mlbundle playground. Runs inside Pyodide only.""" + +import errno +import json +import linecache +import os +import shutil +import sys +import traceback +from importlib import import_module +from pathlib import Path +from types import TracebackType + +# This module exists only in the Pyodide runtime exercised by test:runtime. +from pyodide.code import eval_code_async # pyright: ignore[reportMissingImports] + +FILENAME = "" +WORK_ROOT = Path("/playground") +MAX_BUNDLES = 8 +MIB = 1024 * 1024 +# Lowered with mlbundle's own ArchiveLimits: a browser tab is not a model server. +LIMITS_MIB = { + "max_archive_bytes": 64, + "max_compressed_bytes": 64, + "max_member_bytes": 64, + "max_total_bytes": 128, +} +_runs = 0 + + +def _no_hard_links(*_args: object, **_kwargs: object) -> None: + raise OSError(errno.ENOSYS, "Pyodide's file system has no hard links") + + +def runtime_versions(source_root: str) -> str: + """Prepare the interpreter, import the repository's mlbundle and report versions.""" + if source_root not in sys.path: + sys.path.insert(0, source_root) + # Pyodide omits os.link. mlbundle then uses its documented exclusive-create + # publication, exactly as on other file systems without hard links. + if not hasattr(os, "link"): + os.link = _no_hard_links # pyright: ignore[reportAttributeAccessIssue] + from importlib.metadata import version # noqa: PLC0415 + + import_module("mlbundle") + names = ("mlbundle", "mlschema", "scikit-learn", "skops", "pandas", "numpy") + return json.dumps( + {"python": sys.version.split()[0]} | {name: version(name) for name in names} + ) + + +def _limits(): + from mlbundle import ArchiveLimits # noqa: PLC0415 + + return ArchiveLimits(**{key: value * MIB for key, value in LIMITS_MIB.items()}) + + +def _error_text(error: BaseException) -> str: + return f"{type(error).__name__}: {error}" + + +def _metadata(view) -> dict: + manifest = json.loads(view.manifest.to_bytes()) + integrity = manifest.get("integrity", {}) + members = [ + {"name": str(name), **integrity.get(str(name), {})} for name in view.members + ] + return { + "manifest": manifest, + "members": members, + "schema": view.schema, + "samples": view.samples, + } + + +def _structure(structure: object) -> object: + if structure is None: + return None + from mlbundle.canonical import canonical_dumps # noqa: PLC0415 + + # Canonical JSON keeps non-finite numbers as {"$float": ...} tags. + return json.loads(canonical_dumps(structure, max_bytes=256 * MIB, max_depth=64)) + + +def describe_bundle(path: str, *, verify: bool) -> dict: + """Inspect metadata; when verify is set, also load and recompute model claims. + + Uploaded files are never verified here: verification deserializes the model. + """ + from mlbundle import inspect, load # noqa: PLC0415 + + target = Path(path) + report: dict = {"fileName": target.name, "sizeBytes": target.stat().st_size} + try: + report |= _metadata(inspect(target, limits=_limits())) + except Exception as error: # Every rejection is shown verbatim. + return report | {"status": "rejected", "error": _error_text(error)} + if not verify: + return report | {"status": "inspected", "structure": None} + try: + loaded = load(target, limits=_limits()) + except Exception as error: + return report | {"status": "unverified", "error": _error_text(error)} + return report | {"status": "verified", "structure": _structure(loaded.structure)} + + +def inspect_upload(path: str) -> str: + """Metadata-only inspection of a file chosen by the visitor.""" + try: + return json.dumps(describe_bundle(path, verify=False), allow_nan=False) + finally: + Path(path).unlink(missing_ok=True) + + +def _user_traceback(error: BaseException) -> TracebackType | None: + tb = error.__traceback__ + while tb is not None and tb.tb_frame.f_code.co_filename != FILENAME: + tb = tb.tb_next + return tb + + +def _format_error(error: BaseException) -> str: + lines = traceback.format_exception(type(error), error, _user_traceback(error)) + return "".join(lines).rstrip() + + +def _outcome(value: object) -> dict: + if value is None: + return {"kind": "none"} + try: + text = json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError): + return { + "kind": "repr", + "typeName": type(value).__qualname__, + "text": repr(value), + } + return {"kind": "json", "text": text} + + +def _bundles(directory: Path) -> tuple[list[dict], int]: + found = sorted(p for p in directory.glob("*.mlbundle") if p.is_file()) + reports = [describe_bundle(str(p), verify=True) for p in found[:MAX_BUNDLES]] + for report, path in zip(reports, found, strict=False): + report["path"] = str(path) + return reports, max(0, len(found) - MAX_BUNDLES) + + +async def run(code: str) -> str: + """Run code in a fresh namespace and an empty working directory. + + Every *.mlbundle the code writes into that directory is inspected and then + verified with mlbundle.load, because the visitor's own code produced it. + """ + global _runs # noqa: PLW0603 + _runs += 1 + shutil.rmtree(WORK_ROOT, ignore_errors=True) + directory = WORK_ROOT / f"run-{_runs}" + directory.mkdir(parents=True) + os.chdir(directory) + linecache.cache[FILENAME] = (len(code), None, code.splitlines(True), FILENAME) + namespace = {"__name__": "__main__"} + try: + outcome = _outcome(await eval_code_async(code, namespace, filename=FILENAME)) + except BaseException as error: + outcome = {"kind": "error", "text": _format_error(error)} + finally: + sys.stdout.flush() + sys.stderr.flush() + os.chdir("/") + bundles, skipped = _bundles(directory) + return json.dumps( + {"outcome": outcome, "bundles": bundles, "skipped": skipped}, allow_nan=False + ) diff --git a/playground/src/runtime/protocol.ts b/playground/src/runtime/protocol.ts new file mode 100644 index 0000000..2477d89 --- /dev/null +++ b/playground/src/runtime/protocol.ts @@ -0,0 +1,65 @@ +export interface RuntimeVersions { + python: string; + mlbundle: string; + mlschema: string; + "scikit-learn": string; + skops: string; + pandas: string; + numpy: string; +} + +/** What the last expression of a run produced, as reported by harness.py. */ +export type RunOutcome = + | { kind: "json"; text: string } + | { kind: "repr"; text: string; typeName: string } + | { kind: "none" } + | { kind: "error"; text: string }; + +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +export interface MemberRecord { + name: string; + sha256?: string; + size?: number; +} + +/** + * One bundle as mlbundle reported it. `verified`: inspect and load both passed, so + * model claims and structure were recomputed. `inspected`: metadata only, the model + * was never deserialized. `unverified`: inspect passed, load rejected it. + * `rejected`: inspect rejected it. + */ +export interface BundleReport { + fileName: string; + sizeBytes: number; + source: "generated" | "uploaded"; + status: "verified" | "inspected" | "unverified" | "rejected"; + error?: string; + manifest?: { [key: string]: Json }; + members?: MemberRecord[]; + schema?: { [key: string]: Json } | null; + samples?: { [key: string]: Json }[] | null; + structure?: { [key: string]: Json } | null; + /** The exact bytes mlbundle wrote, for download. Generated bundles only. */ + bytes?: Uint8Array; +} + +export interface RunReport { + outcome: RunOutcome; + bundles: BundleReport[]; + /** Bundles beyond the per-run limit that were not inspected. */ + skipped: number; +} + +export type OutputStream = "stdout" | "stderr"; + +export type ToWorker = + | { type: "run"; id: number; code: string } + | { type: "inspect"; id: number; name: string; data: ArrayBuffer }; + +export type FromWorker = + | { type: "progress"; message: string } + | { type: "ready"; versions: RuntimeVersions } + | { type: "boot-error"; message: string } + | { type: "stream"; id: number; stream: OutputStream; text: string } + | { type: "done"; id: number; report: RunReport; durationMs: number }; diff --git a/playground/src/runtime/session.ts b/playground/src/runtime/session.ts new file mode 100644 index 0000000..4c9fdac --- /dev/null +++ b/playground/src/runtime/session.ts @@ -0,0 +1,100 @@ +import type { PyodideAPI } from "pyodide"; +import type { BundleReport, RunReport, RuntimeVersions } from "./protocol.ts"; +import { PYODIDE_PACKAGES, PYPI_PINS, SOURCE_ROOT } from "./versions.ts"; + +export interface PythonSession { + versions: RuntimeVersions; + run(code: string): Promise; + inspect(name: string, data: Uint8Array): Promise; +} + +/** Package-relative path, e.g. `mlbundle/inspection.py`, to file contents. */ +export type SourceTree = Readonly>; + +/** The [project] version, so importlib.metadata reports what the manifest records. */ +export const projectVersion = (pyproject: string) => { + const match = /^\[project\][^[]*?^version\s*=\s*"([^"]+)"/m.exec(pyproject); + if (!match) throw new Error("pyproject.toml declares no [project] version."); + return match[1]!; +}; + +const writeSources = (pyodide: PyodideAPI, sources: SourceTree, version: string) => { + if (!Object.hasOwn(sources, "mlbundle/__init__.py")) { + throw new Error("The mlbundle sources are missing from this build."); + } + for (const [path, text] of Object.entries(sources)) { + const target = `${SOURCE_ROOT}/${path}`; + pyodide.FS.mkdirTree(target.slice(0, target.lastIndexOf("/"))); + pyodide.FS.writeFile(target, text); + } + // Minimal distribution metadata: mlbundle reads its own version from it. + const info = `${SOURCE_ROOT}/mlbundle-${version}.dist-info`; + pyodide.FS.mkdirTree(info); + pyodide.FS.writeFile( + `${info}/METADATA`, + `Metadata-Version: 2.4\nName: mlbundle\nVersion: ${version}\n`, + ); +}; + +const UPLOAD_PATH = "/uploads/upload.mlbundle"; + +/** + * Prepare a loaded Pyodide instance: install the pinned wheels, write the real + * mlbundle sources and load the execution harness. Shared by the browser worker + * and the Node runtime tests so both exercise the same path. + */ +export const startSession = async ( + pyodide: PyodideAPI, + sources: SourceTree, + pyproject: string, + harness: string, + onProgress: (message: string) => void = () => {}, +): Promise => { + onProgress("Loading scikit-learn and pandas…"); + await pyodide.loadPackage([...PYODIDE_PACKAGES], { messageCallback: () => {} }); + onProgress("Installing pydantic, skops and mlschema…"); + const micropip = pyodide.pyimport("micropip"); + try { + await micropip.install.callKwargs([...PYPI_PINS], { deps: false }); + } finally { + micropip.destroy(); + } + onProgress("Loading mlbundle…"); + writeSources(pyodide, sources, projectVersion(pyproject)); + const namespace = pyodide.toPy({}); + pyodide.runPython(harness, { globals: namespace, filename: "harness.py" }); + const versionsOf = namespace.get("runtime_versions"); + const runCode = namespace.get("run"); + const inspectUpload = namespace.get("inspect_upload"); + const versions = JSON.parse(versionsOf(SOURCE_ROOT)) as RuntimeVersions; + versionsOf.destroy(); + + // Attach the written bytes, then remove the run directory's files. + const collect = (raw: string, source: BundleReport["source"]): RunReport => { + const report = JSON.parse(raw) as RunReport & { bundles: (BundleReport & { path?: string })[] }; + for (const bundle of report.bundles) { + bundle.source = source; + if (bundle.path && bundle.status !== "rejected") { + bundle.bytes = pyodide.FS.readFile(bundle.path) as Uint8Array; + } + delete bundle.path; + } + return report; + }; + + return { + versions, + async run(code) { + return collect(await runCode(code), "generated"); + }, + async inspect(name, data) { + pyodide.FS.mkdirTree("/uploads"); + pyodide.FS.writeFile(UPLOAD_PATH, data); + const bundle = JSON.parse(inspectUpload(UPLOAD_PATH)) as BundleReport; + // The visitor's own file name, never a path: the harness saw a fixed one. + bundle.fileName = name; + bundle.source = "uploaded"; + return { outcome: { kind: "none" }, bundles: [bundle], skipped: 0 }; + }, + }; +}; diff --git a/playground/src/runtime/sources.ts b/playground/src/runtime/sources.ts new file mode 100644 index 0000000..cbfdf05 --- /dev/null +++ b/playground/src/runtime/sources.ts @@ -0,0 +1,15 @@ +import type { SourceTree } from "./session.ts"; +import pyproject from "../../../pyproject.toml?raw"; + +// The repository's real package source, bundled at build time. Never a copy. +// state_fields.json is package data the sklearn adapter reads at import time. +const files = import.meta.glob( + ["../../../src/mlbundle/**/*.py", "../../../src/mlbundle/**/*.json"], + { query: "?raw", import: "default", eager: true }, +); + +export const MLBUNDLE_SOURCES: SourceTree = Object.fromEntries( + Object.entries(files).map(([path, text]) => [path.slice(path.indexOf("mlbundle/")), text]), +); + +export const PYPROJECT = pyproject; diff --git a/playground/src/runtime/versions.ts b/playground/src/runtime/versions.ts new file mode 100644 index 0000000..8b727c3 --- /dev/null +++ b/playground/src/runtime/versions.ts @@ -0,0 +1,40 @@ +// Pinned so every visitor gets the same interpreter and wheels. +export const PYODIDE_VERSION = "314.0.7"; +export const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`; + +// Wheels shipped with this Pyodide release: numpy 2.4.6, pandas 3.0.2, scipy 1.18.0 and +// scikit-learn 1.8.0. mlbundle's sklearn catalog is audited against 1.9.1, which has no +// build for this ABI; the manifest records the versions that actually ran. +export const PYODIDE_PACKAGES = [ + "micropip", + "numpy", + "pandas", + "scipy", + "scikit-learn", +] as const; + +// Pure-Python wheels from PyPI at the versions in the repository's uv.lock. +// Pyodide's pydantic (2.12.5) is below mlbundle's floor; pydantic-core publishes a +// pyemscripten_2026_0 build. The others have no compiled code. +export const PYPI_PINS = [ + "pydantic==2.14.1", + "pydantic-core==2.50.1", + "typing-extensions==4.16.0", + "typing-inspection==0.4.4", + "annotated-types==0.8.0", + "skops==0.16.0", + "prettytable==3.18.0", + "wcwidth==0.9.2", + "packaging==26.3", + "mlschema==0.3.0", +] as const; + +// Where the repository's src/mlbundle tree is written inside the Pyodide file system. +export const SOURCE_ROOT = "/mlbundle/src"; + +export const BOOT_TIMEOUT_MS = 240_000; +export const RUN_TIMEOUT_MS = 60_000; +export const OUTPUT_LIMIT_CHARS = 100_000; + +// Uploads above this size are refused before they are read into the browser. +export const UPLOAD_LIMIT_BYTES = 64 * 1024 * 1024; diff --git a/playground/src/runtime/worker.ts b/playground/src/runtime/worker.ts new file mode 100644 index 0000000..82b6a1e --- /dev/null +++ b/playground/src/runtime/worker.ts @@ -0,0 +1,67 @@ +import type { FromWorker, ToWorker } from "./protocol.ts"; +import harness from "./harness.py?raw"; +import { startSession, type PythonSession } from "./session.ts"; +import { MLBUNDLE_SOURCES, PYPROJECT } from "./sources.ts"; +import { OUTPUT_LIMIT_CHARS, PYODIDE_INDEX_URL } from "./versions.ts"; + +// The DOM and WebWorker libs conflict in one program; type only what is used. +const scope = self as unknown as { + postMessage(message: FromWorker, transfer?: Transferable[]): void; + addEventListener(type: "message", listener: (event: MessageEvent) => void): void; +}; +const post = (message: FromWorker, transfer: Transferable[] = []) => + scope.postMessage(message, transfer); +let runId = 0; +let written = 0; + +const forward = (stream: "stdout" | "stderr") => (line: string) => { + if (runId === 0 || written > OUTPUT_LIMIT_CHARS) return; + written += line.length + 1; + const text = + written > OUTPUT_LIMIT_CHARS ? "\n[Output truncated: over 100,000 characters.]\n" : `${line}\n`; + post({ type: "stream", id: runId, stream, text }); +}; + +const boot = async (): Promise => { + post({ type: "progress", message: "Downloading Python…" }); + // Loaded from the pinned CDN release so the loader always matches its WebAssembly. + const { loadPyodide } = (await import( + /* @vite-ignore */ `${PYODIDE_INDEX_URL}pyodide.mjs` + )) as typeof import("pyodide"); + const pyodide = await loadPyodide({ + indexURL: PYODIDE_INDEX_URL, + stdout: forward("stdout"), + stderr: forward("stderr"), + }); + return startSession(pyodide, MLBUNDLE_SOURCES, PYPROJECT, harness, (message) => + post({ type: "progress", message }), + ); +}; + +const session = boot(); +session.then( + ({ versions }) => post({ type: "ready", versions }), + (error: unknown) => + post({ type: "boot-error", message: error instanceof Error ? error.message : String(error) }), +); + +scope.addEventListener("message", async ({ data }) => { + const python = await session; + runId = data.id; + written = 0; + const started = performance.now(); + try { + const report = + data.type === "run" + ? await python.run(data.code) + : await python.inspect(data.name, new Uint8Array(data.data)); + const transfer = report.bundles.flatMap((bundle) => (bundle.bytes ? [bundle.bytes.buffer] : [])); + post({ type: "done", id: data.id, report, durationMs: performance.now() - started }, transfer); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + const report = { outcome: { kind: "error", text } as const, bundles: [], skipped: 0 }; + post({ type: "done", id: data.id, report, durationMs: 0 }); + } finally { + runId = 0; + } +}); diff --git a/playground/src/settings.ts b/playground/src/settings.ts new file mode 100644 index 0000000..e2141a3 --- /dev/null +++ b/playground/src/settings.ts @@ -0,0 +1,119 @@ +/** Options-menu settings. The catalogs mirror MLForm's playground (themes, recipes, viewports). */ +export const THEMES = [ + { id: "eshkra", label: "eshkra" }, + { id: "m3diseen", label: "M3DISEEN" }, + { id: "dosetailor", label: "DoseTailor" }, + { id: "clickhouse", label: "ClickHouse" }, + { id: "airbnb", label: "Airbnb" }, + { id: "neutral", label: "Neutral" }, + { id: "cobalt", label: "Cobalt" }, + { id: "sage", label: "Sage" }, + { id: "sunset", label: "Sunset" }, + { id: "graphite", label: "Graphite" }, +] as const; +export const STYLES = [ + { id: "default", label: "Default" }, + { id: "minimal", label: "Minimal" }, + { id: "soft", label: "Soft" }, + { id: "contrast", label: "Contrast" }, +] as const; +export const APPEARANCES = [ + { id: "auto", label: "System" }, + { id: "light", label: "Light" }, + { id: "dark", label: "Dark" }, +] as const; +export const VIEWPORTS = [ + { id: "actual", label: "Current viewport" }, + { id: "phone", label: "Phone · 390 × 844", width: 390, height: 844 }, + { id: "tablet", label: "Tablet · 820 × 1180", width: 820, height: 1180 }, + { id: "laptop", label: "Laptop · 1366 × 768", width: 1366, height: 768 }, + { id: "pc", label: "PC · 1920 × 1080", width: 1920, height: 1080 }, + { id: "ultrawide", label: "Ultrawide · 2560 × 1080", width: 2560, height: 1080 }, + { id: "custom", label: "Custom" }, +] as const; +export const SIZE_LIMITS = { min: 1, max: 8192 } as const; + +type Id = T[number]["id"]; +export interface Settings { + theme: Id; + style: Id; + appearance: Id; + viewport: Id; + width: number; + height: number; +} +export const SETTINGS_KEY = "mlbundle-playground-settings"; +export const DEFAULT_SETTINGS: Settings = { + theme: "eshkra", + style: "default", + appearance: "auto", + viewport: "actual", + width: 1280, + height: 800, +}; + +export const validSize = (value: unknown): value is number => + typeof value === "number" && + Number.isInteger(value) && + value >= SIZE_LIMITS.min && + value <= SIZE_LIMITS.max; + +const pick = (items: T, value: unknown, fallback: Id) => + (items.find(({ id }) => id === value)?.id as Id | undefined) ?? fallback; + +/** Each saved field is validated on its own, so one bad value never resets the others. */ +export const parseSettings = (value: unknown): Settings => { + const saved = value && typeof value === "object" ? (value as Record) : {}; + return { + theme: pick(THEMES, saved.theme, DEFAULT_SETTINGS.theme), + style: pick(STYLES, saved.style, DEFAULT_SETTINGS.style), + appearance: pick(APPEARANCES, saved.appearance, DEFAULT_SETTINGS.appearance), + viewport: pick(VIEWPORTS, saved.viewport, DEFAULT_SETTINGS.viewport), + width: validSize(saved.width) ? saved.width : DEFAULT_SETTINGS.width, + height: validSize(saved.height) ? saved.height : DEFAULT_SETTINGS.height, + }; +}; + +export const readSettings = (storage?: Pick): Settings => { + try { + return parseSettings(JSON.parse(storage?.getItem(SETTINGS_KEY) ?? "null")); + } catch { + return { ...DEFAULT_SETTINGS }; + } +}; + +/** Returns false when the choice cannot be stored; it still applies to this visit. */ +export const saveSettings = (storage: Pick | undefined, settings: Settings) => { + try { + storage?.setItem(SETTINGS_KEY, JSON.stringify(settings)); + return storage !== undefined; + } catch { + return false; + } +}; + +/** The simulated viewport, or null when the workspace uses the real window. */ +export const viewportSize = (settings: Settings) => { + if (settings.viewport === "actual") return null; + const preset = VIEWPORTS.find(({ id }) => id === settings.viewport)!; + const size = + "width" in preset + ? { width: preset.width, height: preset.height } + : { width: settings.width, height: settings.height }; + return { ...size, name: preset.label.split(" · ")[0]! }; +}; + +export const applyDesign = (root: HTMLElement, settings: Settings) => { + root.dataset.theme = settings.theme; + root.dataset.style = settings.style; + root.dataset.appearance = settings.appearance; +}; + +/** The browser's storage, or undefined where access is blocked (some privacy modes). */ +export const browserStorage = () => { + try { + return window.localStorage; + } catch { + return undefined; + } +}; diff --git a/playground/src/shell.ts b/playground/src/shell.ts new file mode 100644 index 0000000..88409ce --- /dev/null +++ b/playground/src/shell.ts @@ -0,0 +1,27 @@ +import { icon } from "./icons.ts"; +import { LINKS } from "./links.ts"; +import { MENU } from "./menu.ts"; +import eshkraMark from "./assets/eshkra-mark.png?url"; + +export const createShell = (mark: string) => { + const shell = document.createElement("main"); + shell.className = "app-shell"; + shell.innerHTML = ` +
+ + + MLbundle + + +
+ ${MENU} +
+ `; + return shell; +}; diff --git a/playground/src/styles/base.css b/playground/src/styles/base.css new file mode 100644 index 0000000..8562a79 --- /dev/null +++ b/playground/src/styles/base.css @@ -0,0 +1,85 @@ +:root { + font-family: var(--font-sans); + color: var(--text); + background: var(--bg); + scrollbar-color: var(--text-muted) var(--bg); + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + background: var(--bg); +} + +button, +select, +input { + font: inherit; + touch-action: manipulation; +} + +a { + color: inherit; + text-decoration-thickness: 1px; + text-underline-offset: 0.25em; +} + +::selection { + background: var(--accent); + color: var(--text-inverse); +} + +:focus-visible { + outline: 2px solid var(--text); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.app-shell { + display: flex; + flex-direction: column; + height: 100dvh; + padding-top: var(--header-height); + overflow: hidden; + accent-color: var(--accent); + caret-color: var(--text); +} + +.editor-page { + display: flex; + height: 100dvh; + overflow: hidden; +} +.editor-page > * { + flex: 1; + min-width: 0; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0s !important; + } +} diff --git a/playground/src/styles/controls.css b/playground/src/styles/controls.css new file mode 100644 index 0000000..e5c48bd --- /dev/null +++ b/playground/src/styles/controls.css @@ -0,0 +1,54 @@ +.icon-button { + display: inline-grid; + place-items: center; + flex-shrink: 0; + width: var(--control-size); + height: var(--control-size); + padding: 0; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + cursor: pointer; + transition: background var(--motion-fast); +} +.code-workspace .icon-button { + min-height: 0; + padding: 0; +} +.icon-button:hover:not(:disabled) { + background: var(--hover-surface); +} + +/* Icon-only controls name themselves in a tooltip on hover and on keyboard focus. */ +:where([data-tooltip]) { + position: relative; +} +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + z-index: 60; + top: calc(100% + 0.45rem); + right: 0; + width: max-content; + max-width: min(20rem, 80vw); + padding: 0.35rem 0.55rem; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-elevated); + color: var(--text); + font: 500 0.75rem/1.4 var(--font-sans); + text-align: left; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity var(--motion-fast); +} +[data-tooltip]:focus-visible::after { + opacity: 1; +} +@media (hover: hover) { + [data-tooltip]:hover::after { + opacity: 1; + } +} diff --git a/playground/src/styles/editor.css b/playground/src/styles/editor.css new file mode 100644 index 0000000..143c425 --- /dev/null +++ b/playground/src/styles/editor.css @@ -0,0 +1,37 @@ +:is(.code-editor, .output-json) .cm-editor { + height: 100%; + background: var(--surface); + color: var(--text); +} +:is(.code-editor, .output-json) .cm-scroller { + overflow: auto; + font: var(--code-font-size)/var(--code-line-height) var(--font-mono); + font-variant-ligatures: none; +} +:is(.code-editor, .output-json) .cm-content { + padding: 0.75rem 0; + caret-color: var(--text); +} +:is(.code-editor, .output-json) .cm-gutters { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-muted); +} +.code-editor :is(.cm-activeLine, .cm-activeLineGutter) { + background: var(--hover-surface); +} +:is(.code-editor, .output-json) .cm-editor.cm-focused { + outline: 2px solid var(--text); + outline-offset: -2px; +} +.code-editor .cm-cursor { + border-left-color: var(--text); +} +:is(.code-editor, .output-json) .cm-editor .cm-selectionBackground, +:is(.code-editor, .output-json) .cm-content ::selection { + background: color-mix(in srgb, var(--accent) 30%, transparent) !important; +} +.code-editor .cm-matchingBracket { + background: color-mix(in srgb, var(--accent) 18%, transparent); + outline: 1px solid var(--accent); +} diff --git a/playground/src/styles/header.css b/playground/src/styles/header.css new file mode 100644 index 0000000..121bde9 --- /dev/null +++ b/playground/src/styles/header.css @@ -0,0 +1,80 @@ +/* Brand header, as in MLForm's playground (brand-header.css); the menu floats at its left. */ +.app-brand-header { + position: fixed; + inset: 0 0 auto; + z-index: 40; + height: var(--header-height); + padding: 1rem 1.25rem calc(var(--header-height) - 4rem) 5rem; + background: var(--bg); + display: flex; + align-items: center; + gap: 1rem; +} +.mlbundle-brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + min-width: 0; + color: inherit; + font-size: 1.625rem; + text-decoration: none; +} +.mlbundle-brand img { + width: 1.625rem; + height: 2.5rem; + object-fit: contain; +} +.mlbundle-wordmark { + white-space: nowrap; +} +.mlbundle-wordmark .ml { + font-weight: 800; + font-stretch: 125%; + letter-spacing: -0.01em; +} +.mlbundle-wordmark .rest { + font-weight: 300; + font-stretch: 100%; +} +.app-brand-header a:focus-visible { + outline: 2px solid var(--text); + outline-offset: 4px; +} +.app-brand-header nav { + display: flex; + gap: 0.25rem; + margin-left: auto; +} +.app-brand-header nav .icon-button { + border-color: transparent; + background: transparent; + text-decoration: none; +} +.app-brand-header nav .icon-button:hover { + background: var(--hover-surface); +} +@media (max-width: 620px) { + :root { + --header-height: 4.35rem; + } + .app-brand-header { + gap: 0.65rem; + padding-inline: 4.6rem 0.75rem; + } + .app-brand-header nav { + gap: 0; + } + .mlbundle-brand { + gap: 0.35rem; + font-size: 1.5rem; + } + .mlbundle-brand img { + width: 1.4rem; + height: 2.15rem; + } +} +@media (max-width: 380px) { + .mlbundle-brand { + font-size: 1.3rem; + } +} diff --git a/playground/src/styles/index.css b/playground/src/styles/index.css new file mode 100644 index 0000000..4f6562a --- /dev/null +++ b/playground/src/styles/index.css @@ -0,0 +1,12 @@ +@import "./tokens.css"; +@import "./base.css"; +@import "./header.css"; +@import "./workspace.css"; +@import "./output.css"; +@import "./editor.css"; +@import "./controls.css"; +@import "./menu.css"; +@import "./viewport.css"; +@import "./themes.css"; +@import "./recipes.css"; +@import "./inspector.css"; diff --git a/playground/src/styles/inspector.css b/playground/src/styles/inspector.css new file mode 100644 index 0000000..37e6ab6 --- /dev/null +++ b/playground/src/styles/inspector.css @@ -0,0 +1,226 @@ +/* Bundle inspector: bundle bar, view tabs and the tab panel. */ +.output-banner { + flex-shrink: 0; + margin: 0; + padding: 0.6rem 0.8rem; + border-bottom: var(--border-width) solid var(--border); + color: var(--text-muted); + font-size: 0.8rem; +} + +.inspector-bar { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + min-height: 2.75rem; + padding: 0.35rem 0.75rem; + border-bottom: var(--border-width) solid var(--border); +} +.inspector-title { + flex: 1 1 8rem; + min-width: 0; + overflow: hidden; + font: 0.8rem var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} +.inspector-chip { + flex-shrink: 0; + border-radius: 999px; + padding: 0.1rem 0.55rem; + border: var(--border-width) solid currentColor; + font-size: 0.72rem; + font-weight: 600; +} +.inspector-chip[data-status="verified"] { color: var(--success); } +.inspector-chip[data-status="inspected"] { color: var(--info); } +.inspector-chip:is([data-status="unverified"], [data-status="rejected"]) { color: var(--danger); } + +.inspector-tabs { + flex-shrink: 0; + display: flex; + gap: 0.15rem; + overflow-x: auto; + padding: 0 0.5rem; + border-bottom: var(--border-width) solid var(--border); + scrollbar-width: thin; +} +.code-workspace .inspector-tabs [role="tab"] { + flex-shrink: 0; + min-height: 2.4rem; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--text-muted); + font-weight: 500; +} +.code-workspace .inspector-tabs [role="tab"][aria-selected="true"] { + border-bottom-color: var(--accent); + color: var(--text); +} +.inspector-tabs [role="tab"]:focus-visible, +.inspector-panel:focus-visible, +.inspector-table:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.inspector-panel { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0.75rem 0.9rem 1.5rem; + font-size: 0.85rem; +} +.inspector-panel.is-manifest { + display: flex; + padding: 0; +} +.inspector-panel.is-manifest .output-json { + flex: 1; +} + +.inspector-status { + display: grid; + gap: 0.25rem; + margin-bottom: 0.9rem; + padding: 0.65rem 0.8rem; + border-radius: var(--radius-sm); + border: var(--border-width) solid var(--border); + border-left: 3px solid var(--info); + background: var(--surface-muted); +} +.inspector-status[data-status="verified"] { border-left-color: var(--success); } +.inspector-status:is([data-status="unverified"], [data-status="rejected"]) { + border-left-color: var(--danger); + background: var(--danger-soft); +} +.inspector-status span { color: var(--text-muted); } +.inspector-error { + font: 0.78rem/1.5 var(--font-mono); + color: var(--danger); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.inspector-facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.3rem 1rem; + margin: 0 0 1rem; +} +.inspector-facts dt { color: var(--text-muted); } +.inspector-facts dd { + margin: 0; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.inspector-section { margin-top: 1.1rem; } +.inspector-section h3, +.inspector-table caption { + margin: 0 0 0.4rem; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-align: left; + text-transform: uppercase; +} +.inspector-note { + margin: 0 0 0.6rem; + color: var(--text-muted); + line-height: 1.5; +} + +.inspector-table { + max-width: 100%; + overflow-x: auto; + margin-bottom: 0.6rem; +} +.inspector-table table { + min-width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} +.inspector-table :is(th, td) { + padding: 0.3rem 0.6rem; + border-bottom: var(--border-width) solid var(--border); + text-align: left; + vertical-align: top; +} +.inspector-table td { + font-family: var(--font-mono); + overflow-wrap: anywhere; +} +.inspector-table th { color: var(--text-muted); font-weight: 600; } + +.inspector-bars td:last-child { width: 45%; } +.inspector-bar-fill { + display: block; + height: 0.6rem; + min-width: 1px; + border-radius: 2px; + background: var(--accent); +} +.inspector-bar-fill[data-sign="negative"] { background: var(--info); } + +.tree-outline, +.tree-outline ul { + margin: 0; + padding-left: 1.1rem; + list-style: none; + font: 0.78rem/1.7 var(--font-mono); +} +.tree-outline { padding-left: 0; margin-bottom: 0.8rem; } +.tree-outline ul { border-left: var(--border-width) solid var(--border); } +.tree-outline summary { cursor: pointer; } +.tree-outline summary:focus-visible { outline: 2px solid var(--accent); } +.tree-branch { + color: var(--accent); + font-size: 0.7rem; + text-transform: uppercase; +} +.tree-meta, +.tree-leaf { color: var(--text-muted); } +.tree-host { margin-top: 0.6rem; } + +.inspector-raw { margin-top: 1rem; } +.inspector-raw summary { cursor: pointer; color: var(--text-muted); } +.inspector-raw pre { + max-height: 24rem; + overflow: auto; + margin: 0.4rem 0 0; + padding: 0.6rem; + border-radius: var(--radius-sm); + background: var(--surface-muted); + font: 0.75rem/1.5 var(--font-mono); +} + +.output-console { + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.output-console :is(.output-error, .output-repr, .output-streams) { + max-height: none; + margin: 0; + padding: 0.7rem; + border-radius: var(--radius-sm); + font: 0.78rem/1.55 var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.output-console :is(.output-repr, .output-streams) { background: var(--surface-muted); } +.output-console .output-error { border: 0; } +.output-console .output-note { margin: 0; padding: 0; } + +@media (max-width: 620px) { + .inspector-panel { padding-inline: 0.6rem; } + .inspector-facts { grid-template-columns: minmax(0, 1fr); gap: 0 0; } + .inspector-facts dd { margin-bottom: 0.4rem; } +} diff --git a/playground/src/styles/menu.css b/playground/src/styles/menu.css new file mode 100644 index 0000000..5344523 --- /dev/null +++ b/playground/src/styles/menu.css @@ -0,0 +1,141 @@ +/* Floating options menu, as in MLForm's playground (demo-menu.css). */ +.app-menu { + position: fixed; + top: 1rem; + left: 1rem; + z-index: 50; +} + +.app-menu-button { + width: 3rem; + height: 3rem; + display: grid; + place-content: center; + padding: 0; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-xl); + background: var(--surface-elevated); + color: var(--text); + cursor: pointer; + transition: + transform var(--motion-fast), + background var(--motion-fast); +} +.app-menu-button::after { + right: auto; + left: 0; +} +.app-menu.is-open .app-menu-button::after { + display: none; +} + +.app-menu-button:hover, +.app-menu-button:focus-visible, +.app-menu.is-open .app-menu-button { + transform: translateY(-1px); + background: var(--hover-surface); +} + +.app-menu-button:focus-visible, +.app-menu-option:focus-visible, +.app-menu-field :is(select, input):focus-visible { + outline: 3px solid var(--text); + outline-offset: 3px; +} + +.app-menu-panel { + position: absolute; + top: calc(100% + 0.55rem); + left: 0; + width: min(18rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 5rem); + overflow-y: auto; + padding: 0.75rem; + display: grid; + gap: 0.55rem; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-xl); + background: var(--chrome-bg); + color: var(--text); + box-shadow: var(--shadow); + transform-origin: top left; +} +.app-menu-panel[hidden] { + display: none; +} + +.app-menu-list { + display: grid; + gap: 0.2rem; +} + +.app-menu-section-label { + padding: 0 0.35rem; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; +} + +.app-menu-option { + width: 100%; + min-height: 2.35rem; + padding: 0.55rem 0.7rem; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; +} +.app-menu-option:hover, +.app-menu-option:focus-visible { + background: var(--hover-surface); +} +.app-menu-option.is-active { + background: var(--accent); + color: var(--text-inverse); + font-weight: 700; +} + +.app-menu-settings { + display: grid; + gap: 0.7rem; + padding: 0.8rem 0.35rem 0.2rem; + border-top: var(--border-width) solid var(--border); +} + +.app-menu-field { + display: grid; + gap: 0.25rem; + font-size: 0.85rem; + font-weight: 600; +} +.app-menu-field :is(select, input) { + width: 100%; + min-width: 0; + min-height: 2.35rem; + padding: 0.35rem 0.55rem; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-weight: 400; + cursor: pointer; +} +.app-menu-field input { + cursor: text; +} + +.app-menu-custom-size { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; +} + +@media (forced-colors: active) { + .app-menu-option.is-active { + forced-color-adjust: none; + background: Highlight; + color: HighlightText; + } +} diff --git a/playground/src/styles/output.css b/playground/src/styles/output.css new file mode 100644 index 0000000..c344657 --- /dev/null +++ b/playground/src/styles/output.css @@ -0,0 +1,23 @@ +.code-output { + position: relative; +} +.output-json { + min-height: 6rem; + overflow: auto; +} +.code-output .output-note { + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.8rem; +} +.output-error { + background: var(--danger-soft); + color: var(--danger); +} +/* Author display rules (flex, grid) must not override the hidden attribute. */ +[hidden] { + display: none !important; +} +.console-stderr { + color: var(--danger); +} diff --git a/playground/src/styles/recipes.css b/playground/src/styles/recipes.css new file mode 100644 index 0000000..1512f66 --- /dev/null +++ b/playground/src/styles/recipes.css @@ -0,0 +1,23 @@ +/* MLForm's style recipes, applied to the playground chrome. Default is tokens.css. */ +:root[data-style="minimal"] { + /* Compact density, no motion, no shadows. */ + --control-size: 2rem; + --motion-fast: 0ms linear; + --shadow: none; +} +:root[data-style="soft"] { + --chrome-bg: + radial-gradient(circle at top left, color-mix(in srgb, var(--accent) 10%, transparent), transparent 42%), + linear-gradient( + 180deg, + color-mix(in srgb, var(--surface) 92%, var(--surface-elevated)), + color-mix(in srgb, var(--surface-muted) 88%, var(--surface-elevated)) + ); +} +:root[data-style="contrast"] { + /* Spacious density with stronger borders. */ + --control-size: 2.5rem; + --border-width: 1.5px; + --border-strong: var(--text); + --border: var(--text); +} diff --git a/playground/src/styles/themes.css b/playground/src/styles/themes.css new file mode 100644 index 0000000..9ad27c7 --- /dev/null +++ b/playground/src/styles/themes.css @@ -0,0 +1,154 @@ +/* MLForm's other design systems (eshkra lives in tokens.css). Colors and radii only: + the playground keeps its own fonts and needs no form runtime. */ +:root[data-theme="m3diseen"] { + --radius-lg: 8px; + --radius-xl: 12px; + --bg: light-dark(#9092c7, #161722); + --surface: light-dark(#ffffff, #1e2030); + --surface-muted: light-dark(#f3f3f8, #252739); + --surface-elevated: light-dark(#ffffff, #2c2e42); + --text: light-dark(#212529, #f3f3fa); + --text-muted: light-dark(#54566c, #bfc1d7); + --text-inverse: light-dark(#ffffff, #161722); + --border: light-dark(#d9dae6, #3c3e55); + --border-strong: light-dark(#b9bbce, #555875); + --accent: light-dark(#5c5f9e, #b0b3e4); + --success: light-dark(#246549, #80d6a9); + --danger: light-dark(#a73342, #ffa2ad); + --danger-soft: light-dark(rgba(167, 51, 66, 0.08), rgba(255, 162, 173, 0.12)); + --hover-surface: light-dark(#e9e9f4, #383b53); +} +:root[data-theme="dosetailor"] { + --radius-lg: 8px; + --radius-xl: 12px; + --bg: light-dark(#a9a4e3, #191626); + --surface: light-dark(#ffffff, #221e33); + --surface-muted: light-dark(#f5f4fa, #2b2540); + --surface-elevated: light-dark(#ffffff, #322b48); + --text: light-dark(#212529, #f6f3ff); + --text-muted: light-dark(#5e5975, #c9c0de); + --text-inverse: light-dark(#ffffff, #191626); + --border: light-dark(#dfdceb, #463c5e); + --border-strong: light-dark(#beb8d4, #625578); + --accent: light-dark(#655dc6, #b7aaf7); + --success: light-dark(#246549, #80d6a9); + --danger: light-dark(#a73342, #ffa2ad); + --danger-soft: light-dark(rgba(167, 51, 66, 0.08), rgba(255, 162, 173, 0.12)); + --hover-surface: light-dark(#eeecfa, #403657); +} +:root[data-theme="clickhouse"] { + --radius-xl: 12px; + --bg: light-dark(#f7f7f2, #0a0a0a); + --surface: light-dark(#ffffff, #121212); + --surface-muted: light-dark(#eeeeea, #1a1a1a); + --surface-elevated: light-dark(#f4f4ef, #242424); + --text: light-dark(#0a0a0a, #ffffff); + --text-muted: light-dark(#5a5a5a, #cccccc); + --text-inverse: light-dark(#0a0a0a, #0a0a0a); + --border: light-dark(#d8d8cf, #2a2a2a); + --border-strong: light-dark(#bdbdaf, #3a3a3a); + --accent: light-dark(#e6eb52, #faff69); + --success: light-dark(#126d31, #22c55e); + --danger: light-dark(#b32d2d, #ff7770); + --danger-soft: light-dark(rgba(199, 57, 57, 0.12), rgba(239, 68, 68, 0.16)); + --hover-surface: light-dark(#eeeeea, #242424); +} +:root[data-theme="airbnb"] { + --radius-sm: 8px; + --radius-lg: 20px; + --radius-xl: 32px; + --bg: light-dark(#ffffff, #121212); + --surface: light-dark(#ffffff, #1c1c1c); + --surface-muted: light-dark(#f7f7f7, #262626); + --surface-elevated: light-dark(#ffffff, #2b2b2b); + --text: light-dark(#222222, #f7f7f7); + --text-muted: light-dark(#6a6a6a, #b0b0b0); + --text-inverse: light-dark(#222222, #121212); + --border: light-dark(#dddddd, #3a3a3a); + --border-strong: light-dark(#c1c1c1, #505050); + --accent: light-dark(#ff385c, #ff385c); + --success: light-dark(#007508, #2fbd59); + --danger: light-dark(#c13515, #ff6b5f); + --danger-soft: light-dark(rgba(193, 53, 21, 0.12), rgba(255, 107, 95, 0.16)); + --hover-surface: light-dark(#f2f2f2, #333333); +} +:root[data-theme="neutral"] { + --bg: light-dark(#f4f6f8, #12161c); + --surface: light-dark(#ffffff, #181d24); + --surface-muted: light-dark(#f0f3f6, #212832); + --surface-elevated: light-dark(#ffffff, #202731); + --text: light-dark(#172330, #ebf1f7); + --text-muted: light-dark(#526170, #a5b1bd); + --text-inverse: light-dark(#ffffff, #12161c); + --border: light-dark(#d9e1e7, #313a47); + --border-strong: light-dark(#b9c5cf, #414b58); + --accent: light-dark(#2456c7, #71a7ff); + --success: light-dark(#16704f, #42c88e); + --danger: light-dark(#b93636, #ff7a72); + --danger-soft: light-dark(rgba(185, 54, 54, 0.08), rgba(255, 122, 114, 0.14)); + --hover-surface: light-dark(#e8edf2, #2a3440); +} +:root[data-theme="cobalt"] { + --bg: light-dark(#eef4fb, #0d1524); + --surface: light-dark(#ffffff, #111c30); + --surface-muted: light-dark(#e5eef8, #16233b); + --surface-elevated: light-dark(#ffffff, #1a2944); + --text: light-dark(#11243a, #edf4ff); + --text-muted: light-dark(#49627d, #a3b5cf); + --text-inverse: light-dark(#ffffff, #0d1524); + --border: light-dark(#c6d7ea, #243552); + --border-strong: light-dark(#afc6dd, #304465); + --accent: light-dark(#1f5eff, #68a0ff); + --success: light-dark(#167347, #3ad38a); + --danger: light-dark(#bd3434, #ff7f86); + --danger-soft: light-dark(rgba(214, 69, 69, 0.12), rgba(255, 127, 134, 0.14)); + --hover-surface: light-dark(#d8e5f6, #1f3151); +} +:root[data-theme="sage"] { + --bg: light-dark(#f2f6f1, #121a15); + --surface: light-dark(#fbfdfb, #182119); + --surface-muted: light-dark(#e8efe6, #203024); + --surface-elevated: light-dark(#ffffff, #24372a); + --text: light-dark(#203126, #edf5ef); + --text-muted: light-dark(#526557, #a8b9aa); + --text-inverse: light-dark(#ffffff, #121a15); + --border: light-dark(#cfdbcf, #304234); + --border-strong: light-dark(#bbccbc, #3f5544); + --accent: light-dark(#2f7d57, #59b07d); + --success: light-dark(#256c44, #67d595); + --danger: light-dark(#a83f3d, #ff8e8a); + --danger-soft: light-dark(rgba(185, 74, 72, 0.12), rgba(255, 142, 138, 0.14)); + --hover-surface: light-dark(#dde9db, #2a3d30); +} +:root[data-theme="sunset"] { + --bg: light-dark(#fff6f0, #1e1512); + --surface: light-dark(#fffdfb, #261b18); + --surface-muted: light-dark(#ffe9dd, #32231f); + --surface-elevated: light-dark(#ffffff, #392925); + --text: light-dark(#3b241d, #fff1ea); + --text-muted: light-dark(#7f5a50, #d3b0a3); + --text-inverse: light-dark(#26140e, #1e1512); + --border: light-dark(#f0cdbb, #513830); + --border-strong: light-dark(#e0b59f, #68473d); + --accent: light-dark(#d96a3a, #ff986b); + --success: light-dark(#25734f, #57c694); + --danger: light-dark(#b43e38, #ff8d86); + --danger-soft: light-dark(rgba(202, 74, 69, 0.12), rgba(255, 141, 134, 0.14)); + --hover-surface: light-dark(#ffdcca, #422d27); +} +:root[data-theme="graphite"] { + --bg: light-dark(#f1f4f7, #0f1318); + --surface: light-dark(#ffffff, #171c22); + --surface-muted: light-dark(#e6ebf1, #202731); + --surface-elevated: light-dark(#ffffff, #24303d); + --text: light-dark(#202934, #ebf1f7); + --text-muted: light-dark(#5d6977, #9ca8b5); + --text-inverse: light-dark(#ffffff, #0f1318); + --border: light-dark(#cfd7e1, #2c3642); + --border-strong: light-dark(#bbc6d3, #3b4754); + --accent: light-dark(#375a7f, #4ea1ff); + --success: light-dark(#176c47, #37c78b); + --danger: light-dark(#be3a34, #ff6a61); + --danger-soft: light-dark(rgba(190, 58, 52, 0.12), rgba(255, 106, 97, 0.14)); + --hover-surface: light-dark(#dbe2ea, #24303d); +} diff --git a/playground/src/styles/tokens.css b/playground/src/styles/tokens.css new file mode 100644 index 0000000..1700091 --- /dev/null +++ b/playground/src/styles/tokens.css @@ -0,0 +1,46 @@ +/* The eshkra design system, MLForm's default theme; themes.css holds the others. + light-dark() follows color-scheme: the system's, or the visitor's choice. */ +:root { + color-scheme: light dark; + --font-sans: "Mona Sans Variable", "Mona Sans", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace; + --radius-sm: 6px; + --radius-lg: 12px; + --radius-xl: 14px; + --border-width: 1px; + --motion-fast: 120ms ease; + --shadow: 0 10px 30px rgb(0 0 0 / 0.08); + --control-size: 2.25rem; + --code-font-size: 0.8rem; + --code-line-height: 1.6; + --header-height: 4.75rem; + + --bg: light-dark(#ffffff, #0a0a0d); + --surface: light-dark(#ffffff, #131318); + --surface-muted: light-dark(#f7f7f9, #1a1a21); + --surface-elevated: light-dark(#ffffff, #1c1c24); + --text: light-dark(#0b0b10, #f4f4f6); + --text-muted: light-dark(#666675, #b6b6c2); + --text-inverse: light-dark(#ffffff, #0a0a0d); + --border: light-dark(#e7e7ee, #24242c); + --border-strong: light-dark(#d2d2dc, #34343e); + /* The ink shade keeps small orange text readable on white. */ + --accent: light-dark(#c2410c, #ff9a55); + --success: light-dark(#16704f, #65d2a1); + --info: light-dark(#3b4fc4, #9aa8ff); + --danger: light-dark(#b93636, #ff8e8a); + --danger-soft: light-dark(rgba(185, 54, 54, 0.08), rgba(255, 142, 138, 0.12)); + --hover-surface: light-dark(#fff0e8, #29221f); + /* Derived roles follow whichever design system sets the palette. */ + --button-bg: var(--text); + --button-text: var(--surface); + --chrome-bg: var(--surface-elevated); +} + +:root[data-appearance="light"] { + color-scheme: light; +} + +:root[data-appearance="dark"] { + color-scheme: dark; +} diff --git a/playground/src/styles/viewport.css b/playground/src/styles/viewport.css new file mode 100644 index 0000000..eca0c97 --- /dev/null +++ b/playground/src/styles/viewport.css @@ -0,0 +1,51 @@ +/* The editor frame fills the stage, or simulates a device size as in MLForm's preview.css. */ +.app-stage { + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} +.app-preview, +.app-preview-viewport, +.app-preview-frame { + width: 100%; + height: 100%; +} +.app-preview-frame { + display: block; + border: 0; + background: var(--bg); +} + +.app-stage.is-preview { + overflow: auto; + padding: 0 1rem; + border-top: var(--border-width) solid var(--border); +} +.is-preview .app-preview { + display: grid; + justify-items: center; + align-content: start; + gap: 0.55rem; + height: auto; + padding-block: 0.75rem 1rem; +} +.is-preview .app-preview-label { + color: var(--text-muted); + font: 700 0.82rem/1.5 var(--font-mono); + font-variant-ligatures: none; + text-align: center; +} +.is-preview .app-preview-viewport { + position: relative; + overflow: hidden; + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-lg); + box-sizing: content-box; +} +.is-preview .app-preview-frame { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; +} diff --git a/playground/src/styles/workspace.css b/playground/src/styles/workspace.css new file mode 100644 index 0000000..3977922 --- /dev/null +++ b/playground/src/styles/workspace.css @@ -0,0 +1,210 @@ +.code-workspace { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.code-toolbar { + /* Above the panes, so control tooltips can overlap them. */ + position: relative; + z-index: 5; + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + min-height: 2.75rem; + padding: 0.35rem 0.75rem; + border-block: var(--border-width) solid var(--border); + background: var(--chrome-bg); +} + +.code-select { + display: flex; + min-width: 0; +} +.code-select select { + max-width: 100%; + min-width: 0; +} +.code-position { + margin-right: auto; +} + +.code-workspace button, +.code-workspace select { + min-height: var(--control-size); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 0.3rem 0.6rem; + background: var(--surface); + color: var(--text); + font-size: 0.85rem; + cursor: pointer; +} +.code-workspace button:hover:not(:disabled) { + background: var(--hover-surface); +} +.code-workspace :is(button, select):disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.code-workspace .code-run { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border-color: var(--button-bg); + background: var(--button-bg); + color: var(--button-text); + font-weight: 600; +} +.code-workspace .code-run:hover:not(:disabled) { + border-color: var(--accent); + background: var(--accent); + color: var(--text-inverse); +} + +.code-status { + display: grid; + place-items: center; + width: var(--control-size); + height: var(--control-size); + flex-shrink: 0; + color: var(--text-muted); +} +.code-status[data-symbol="check"] { color: var(--success); } +.code-status[data-symbol="error"] { color: var(--danger); } +.code-status[data-symbol="loading"] svg { animation: status-spin 1s linear infinite; } +@keyframes status-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .code-status[data-symbol="loading"] svg { animation: none; } +} + +.code-notice { + flex-basis: 100%; + margin: 0; + font-size: 0.8rem; +} + +.code-split { + flex: 1; + display: grid; + min-width: 0; + min-height: 0; + grid-template-columns: minmax(0, var(--code-share)) 0.75rem minmax(0, var(--output-share)); + grid-template-rows: minmax(0, 1fr); +} +.code-source { + grid-column: 1; + grid-row: 1; +} +.code-separator { + grid-column: 2; + grid-row: 1; +} +.code-output { + grid-column: 3; + grid-row: 1; +} +.code-split[data-position="right"] { + grid-template-columns: minmax(0, var(--output-share)) 0.75rem minmax(0, var(--code-share)); +} +.code-split[data-position="right"] .code-source { + grid-column: 3; +} +.code-split[data-position="right"] .code-output { + grid-column: 1; +} +.code-split:is([data-position="top"], [data-position="bottom"]) { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, var(--code-share)) 0.75rem minmax(0, var(--output-share)); +} +.code-split:is([data-position="top"], [data-position="bottom"]) > * { + grid-column: 1; +} +.code-split:is([data-position="top"], [data-position="bottom"]) .code-output { + grid-row: 3; +} +.code-split:is([data-position="top"], [data-position="bottom"]) .code-separator { + grid-row: 2; + cursor: row-resize; +} +.code-split[data-position="bottom"] { + grid-template-rows: minmax(0, var(--output-share)) 0.75rem minmax(0, var(--code-share)); +} +.code-split[data-position="bottom"] .code-source { + grid-row: 3; +} +.code-split[data-position="bottom"] .code-output { + grid-row: 1; +} + +.code-pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface); +} +.code-editor { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.code-separator { + display: grid; + place-items: center; + cursor: col-resize; + touch-action: none; + background: var(--bg); + border-inline: var(--border-width) solid var(--border); +} +.code-separator::after { + content: ""; + width: 2px; + height: 2rem; + border-radius: 2px; + background: var(--border-strong); +} +.code-separator:hover::after, +.code-separator:focus-visible::after { + background: var(--accent); +} +.code-separator[aria-orientation="horizontal"] { + border-inline: 0; + border-block: var(--border-width) solid var(--border); +} +.code-separator[aria-orientation="horizontal"]::after { + width: 2rem; + height: 2px; +} +.code-split.is-resizing { + user-select: none; +} + +@media (max-width: 620px) { + .code-toolbar { + padding-inline: 0.4rem; + } + .code-select:not(.code-position) { + flex: 1 1 100%; + } + .code-select:not(.code-position) select { + width: 100%; + } + .code-position { + margin-right: auto; + flex: 1; + } +} + +@media (forced-colors: active) { + .code-separator::after { + background: ButtonText; + } +} diff --git a/playground/src/viewport.ts b/playground/src/viewport.ts new file mode 100644 index 0000000..661e3d6 --- /dev/null +++ b/playground/src/viewport.ts @@ -0,0 +1,53 @@ +import { applyDesign, viewportSize, type Settings } from "./settings.ts"; + +/** + * The editor always runs in one same-origin frame. Viewport changes only resize or scale + * that frame, so the code, the output and the running Python survive every change. + */ +export const mountViewport = (stage: HTMLElement, initial: Settings) => { + let settings = initial; + stage.innerHTML = `
+ +
+
`; + const preview = stage.querySelector(".app-preview")!; + const label = stage.querySelector(".app-preview-label")!; + const viewport = stage.querySelector(".app-preview-viewport")!; + const frame = stage.querySelector("iframe")!; + const syncDesign = () => { + const root = frame.contentDocument?.documentElement; + if (root) applyDesign(root, settings); + }; + const resize = () => { + const size = viewportSize(settings); + stage.classList.toggle("is-preview", size !== null); + label.hidden = size === null; + if (!size) { + viewport.removeAttribute("style"); + frame.removeAttribute("style"); + return; + } + const { width, height, name } = size; + // Scale down to fit the stage width, as MLForm's preview does; height may scroll. + const room = preview.clientWidth - (viewport.offsetWidth - viewport.clientWidth); + const scale = Math.min(1, Math.max(1, room) / width); + viewport.style.width = `${width * scale}px`; + viewport.style.height = `${height * scale}px`; + frame.style.width = `${width}px`; + frame.style.height = `${height}px`; + frame.style.transform = scale < 1 ? `scale(${scale})` : ""; + label.textContent = `${name} · ${width} × ${height} px${scale < 1 ? ` · ${Math.round(scale * 100)} %` : ""}`; + }; + frame.addEventListener("load", syncDesign); + const url = new URL(window.location.href); + url.search = "?editor=1"; + url.hash = ""; + frame.src = url.href; + new ResizeObserver(resize).observe(stage); + resize(); + return (next: Settings) => { + settings = next; + syncDesign(); + resize(); + }; +}; diff --git a/playground/src/workspace/editor.ts b/playground/src/workspace/editor.ts new file mode 100644 index 0000000..b789bf6 --- /dev/null +++ b/playground/src/workspace/editor.ts @@ -0,0 +1,69 @@ +import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory } from "@codemirror/commands"; +import { python } from "@codemirror/lang-python"; +import { bracketMatching, indentOnInput, indentUnit } from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { EditorView, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view"; +import { codeHighlighting } from "./highlight.ts"; + +export const createCodeEditor = ( + parent: HTMLElement, + code: string, + onChange: (value: string) => void, + onRun: () => void, +) => { + const editor = new EditorView({ + parent, + state: EditorState.create({ + doc: code, + extensions: [ + lineNumbers(), + history(), + highlightActiveLine(), + indentOnInput(), + indentUnit.of(" "), + bracketMatching(), + python(), + codeHighlighting, + // Escape then Tab leaves the editor, so indenting with Tab does not trap focus. + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + EditorView.contentAttributes.of({ + "aria-label": "Python code", + spellcheck: "false", + autocorrect: "off", + autocapitalize: "off", + }), + EditorView.updateListener.of((update) => { + if (update.docChanged) onChange(update.state.doc.toString()); + }), + ], + }), + }); + const runShortcut = (event: KeyboardEvent) => { + if (event.key !== "Enter" || !(event.ctrlKey || event.metaKey) || event.altKey) return; + if (event.isComposing) return; + // Handle this app command before CodeMirror's Android Enter synthesis drops modifiers. + event.preventDefault(); + event.stopPropagation(); + onRun(); + }; + editor.contentDOM.addEventListener("keydown", runShortcut, true); + return { + setCode(value: string) { + editor.dispatch({ + ...(value !== editor.state.doc.toString() + ? { + changes: { from: 0, to: editor.state.doc.length, insert: value }, + annotations: isolateHistory.of("full"), + } + : {}), + selection: { anchor: 0 }, + effects: EditorView.scrollIntoView(0, { y: "start" }), + }); + }, + focus: () => editor.focus(), + destroy() { + editor.contentDOM.removeEventListener("keydown", runShortcut, true); + editor.destroy(); + }, + }; +}; diff --git a/playground/src/workspace/examples.ts b/playground/src/workspace/examples.ts new file mode 100644 index 0000000..02163eb --- /dev/null +++ b/playground/src/workspace/examples.ts @@ -0,0 +1,28 @@ +const sources = import.meta.glob("../examples/*.py", { + query: "?raw", + import: "default", + eager: true, +}); + +export interface CodeExample { + id: string; + label: string; + code: string; +} + +const CATALOG = [ + ["classifier", "Classifier with schema"], + ["regressor", "Random forest regressor"], + ["samples", "Explicit samples and one-hot"], + ["pipeline", "sklearn Pipeline"], + ["errors", "Rejected models"], +] as const; + +export const CODE_EXAMPLES: readonly CodeExample[] = CATALOG.map(([id, label]) => { + const code = sources[`../examples/${id}.py`]; + if (code === undefined) throw new Error(`Missing example file: ${id}.py`); + return { id, label, code }; +}); + +export const getCodeExample = (id: unknown): CodeExample => + CODE_EXAMPLES.find((example) => example.id === id) ?? CODE_EXAMPLES[0]!; diff --git a/playground/src/workspace/highlight.ts b/playground/src/workspace/highlight.ts new file mode 100644 index 0000000..bedddde --- /dev/null +++ b/playground/src/workspace/highlight.ts @@ -0,0 +1,12 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +export const codeHighlighting = syntaxHighlighting( + HighlightStyle.define([ + { tag: [tags.keyword, tags.controlKeyword], color: "var(--accent)", fontWeight: "600" }, + { tag: [tags.string, tags.number, tags.bool, tags.null], color: "var(--success)" }, + { tag: tags.comment, color: "var(--text-muted)", fontStyle: "italic" }, + { tag: [tags.propertyName, tags.definition(tags.variableName)], color: "var(--info)" }, + { tag: [tags.className, tags.typeName], color: "var(--accent)", fontStyle: "italic" }, + ]), +); diff --git a/playground/src/workspace/mount.ts b/playground/src/workspace/mount.ts new file mode 100644 index 0000000..8f9381a --- /dev/null +++ b/playground/src/workspace/mount.ts @@ -0,0 +1,215 @@ +import { icon, labelIcon } from "../icons.ts"; +import { formatBytes } from "../inspector/dom.ts"; +import { createPythonRuntime, type RunResult, type RuntimeState } from "../runtime/client.ts"; +import { RUN_TIMEOUT_MS, UPLOAD_LIMIT_BYTES } from "../runtime/versions.ts"; +import { createCodeEditor } from "./editor.ts"; +import { CODE_EXAMPLES, getCodeExample } from "./examples.ts"; +import { createOutputPanel } from "./output.ts"; +import { bindCodeSplit } from "./split.ts"; +import { isCodePosition, readWorkspaceState, saveWorkspaceState } from "./state.ts"; +import { createStatus } from "./status.ts"; + +const MARKUP = ` +
+ + + + + + + + + +
+
+
+
+
+ +
+
+`; + +const failedRead = (text: string): RunResult => ({ + kind: "finished", + durationMs: 0, + report: { outcome: { kind: "error", text }, bundles: [], skipped: 0 }, +}); + +export const mountWorkspace = (container: HTMLElement, storage: Storage | undefined) => { + const first = CODE_EXAMPLES[0]!; + let state = readWorkspaceState(storage, { + code: first.code, + exampleId: first.id, + // Narrow screens start stacked; the position control still offers every layout. + position: window.innerWidth < 720 ? "top" : "left", + ratio: 50, + }); + let runtimeState: RuntimeState = { status: "booting", message: "Starting Python…" }; + let autoRun = true; + + const shell = document.createElement("section"); + shell.className = "code-workspace"; + shell.setAttribute("aria-label", "Python code and bundle inspector"); + shell.innerHTML = MARKUP; + container.replaceChildren(shell); + const part = (role: string) => + shell.querySelector(`[data-role="${role}"]`)!; + const exampleSelect = part("example"); + const positionSelect = part("position"); + const runButton = part("run"); + const uploadButton = part("upload"); + const fileInput = part("file"); + const status = createStatus(part("status")); + const notice = part("notice"); + exampleSelect.replaceChildren(...CODE_EXAMPLES.map(({ id, label }) => new Option(label, id))); + exampleSelect.value = getCodeExample(state.exampleId).id; + positionSelect.value = state.position; + const output = createOutputPanel(shell.querySelector(".code-output")!); + + const save = () => { + if (saveWorkspaceState(storage, state)) return; + notice.hidden = false; + notice.textContent = "Saving in this browser is unavailable. Copy your code before leaving."; + }; + const renderRunButton = () => { + const running = runtimeState.status === "running"; + if (running) labelIcon(runButton, "stop", "Stop"); + else labelIcon(runButton, "run", "Run", "Run (Ctrl/⌘ + Enter)"); + runButton.setAttribute("aria-description", running + ? "Stop this run. Python restarts." + : `Run the code (Ctrl/⌘ + Enter). Runs stop after ${RUN_TIMEOUT_MS / 1000} seconds.`); + runButton.disabled = runtimeState.status === "failed"; + runButton.setAttribute("aria-busy", String(running)); + // A new example would otherwise show the result of the code still running. + exampleSelect.disabled = running; + part("reset").disabled = running; + uploadButton.disabled = runtimeState.status !== "ready"; + }; + + const run = async () => { + if (runtimeState.status === "running") return; + if (runtimeState.status !== "ready") { + autoRun = runtimeState.status === "booting"; + if (autoRun) status.show("Will run when Python is ready", "loading"); + return; + } + output.begin(); + const result = await python.run(state.code); + output.finish(result, "run"); + if (result.kind === "finished") { + const failed = result.report.outcome.kind === "error"; + status.show(failed ? "Error" : "Done", failed ? "error" : "check"); + } else if (result.kind === "timeout") status.show("Timed out", "error"); + else if (result.kind === "cancelled") status.show("Stopped", "stop"); + }; + + const inspectFile = async () => { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file || runtimeState.status !== "ready") return; + output.begin(); + if (file.size > UPLOAD_LIMIT_BYTES) { + // Refused before reading, so a huge file never enters the tab's memory. + output.finish(failedRead(`${file.name} is ${formatBytes(file.size)}. The playground inspects files up to ${formatBytes(UPLOAD_LIMIT_BYTES)}; use the mlbundle CLI for larger bundles.`), "inspect"); + status.show("File too large", "error"); + return; + } + let data: ArrayBuffer; + try { + data = await file.arrayBuffer(); + } catch { + output.finish(failedRead(`${file.name} could not be read.`), "inspect"); + status.show("Error", "error"); + return; + } + const result = await python.inspect(file.name, data); + output.finish(result, "inspect"); + if (result.kind === "finished") { + const rejected = result.report.bundles[0]?.status !== "inspected"; + status.show(rejected ? "Rejected" : "Inspected", rejected ? "error" : "check"); + } else if (result.kind === "timeout") status.show("Timed out", "error"); + }; + + const python = createPythonRuntime( + () => new Worker(new URL("../runtime/worker.ts", import.meta.url), { type: "module" }), + { + onState(next) { + runtimeState = next; + renderRunButton(); + if (next.status === "booting") { + status.show(next.message, "loading"); + output.showBooting(next.message); + } else if (next.status === "failed") { + status.show("Python unavailable", "error"); + output.showFailure(next.message); + } else if (next.status === "running") status.show("Running…", "loading"); + else { + output.showReady(); + if (!["Done", "Error", "Inspected", "Rejected", "File too large"].includes(status.label)) { + status.show("Ready"); + } + if (autoRun) { + autoRun = false; + void run(); + } + } + }, + onStream: (stream, text) => output.stream(stream, text), + }, + ); + + const editor = createCodeEditor( + shell.querySelector(".code-editor")!, + state.code, + (code) => { + state = { ...state, code }; + save(); + }, + () => void run(), + ); + const split = bindCodeSplit( + shell.querySelector(".code-split")!, + shell.querySelector(".code-separator")!, + state.position, + state.ratio, + (position, ratio) => { + state = { ...state, position, ratio }; + save(); + }, + ); + + const loadExample = (id: string) => { + const example = getCodeExample(id); + state = { ...state, exampleId: example.id, code: example.code }; + editor.setCode(example.code); + save(); + void run(); + }; + runButton.addEventListener("click", () => { + if (runtimeState.status === "running") python.cancel(); + else void run(); + }); + exampleSelect.addEventListener("change", () => loadExample(exampleSelect.value)); + part("reset").addEventListener("click", () => { + loadExample(state.exampleId); + editor.focus(); + }); + part("restart").addEventListener("click", () => { + autoRun = false; + python.restart(); + }); + uploadButton.addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", () => void inspectFile()); + positionSelect.addEventListener("change", () => { + if (isCodePosition(positionSelect.value)) split.setPosition(positionSelect.value); + }); + renderRunButton(); +}; diff --git a/playground/src/workspace/output.ts b/playground/src/workspace/output.ts new file mode 100644 index 0000000..4ba7257 --- /dev/null +++ b/playground/src/workspace/output.ts @@ -0,0 +1,92 @@ +import { createInspector } from "../inspector/panel.ts"; +import { h } from "../inspector/dom.ts"; +import type { RunResult } from "../runtime/client.ts"; +import type { OutputStream, RunReport } from "../runtime/protocol.ts"; + +const RESTART_HINT = "Check your connection, then choose Restart Python."; +const NO_BUNDLE = + "This run wrote no .mlbundle file. Call export(model, \"name.mlbundle\", …) to write one into the run's working directory."; + +/** + * The right pane: the bundle inspector, with the run's console as its last tab. + * The console holds errors, the last expression and print() output. + */ +export const createOutputPanel = (pane: HTMLElement) => { + const error = h("pre", { class: "output-error", role: "alert", hidden: "" }); + const message = h("p", { class: "output-note", role: "status" }); + const result = h("pre", { class: "output-repr", tabindex: "0", "aria-label": "Last expression", hidden: "" }); + const streams = h("pre", { class: "output-streams", tabindex: "0", "aria-label": "Console output", hidden: "" }); + const consoleView = h("div", { class: "output-console" }, error, message, result, streams); + const notice = h("p", { class: "output-banner", role: "status", hidden: "" }); + pane.append(notice); + const inspector = createInspector(pane, consoleView); + let bootFailed = false; + + const setText = (element: HTMLElement, text: string) => { + element.hidden = !text; + element.textContent = text; + }; + const showFailure = (text: string) => { + setText(error, `${text}\n${RESTART_HINT}`); + setText(notice, "Python is unavailable. See Console."); + bootFailed = true; + inspector.show("console"); + }; + const showReport = (report: RunReport, label: string) => { + const { outcome } = report; + setText(error, outcome.kind === "error" ? outcome.text : ""); + setText(result, outcome.kind === "json" || outcome.kind === "repr" ? outcome.text : ""); + const skipped = report.skipped ? ` ${report.skipped} more bundle(s) were not inspected (limit 8 per run).` : ""; + setText(message, `${label}${skipped}`); + inspector.setBundles(report.bundles, outcome.kind === "error" ? "The code raised an error before writing a bundle. See Console." : NO_BUNDLE); + if (outcome.kind === "error" && report.bundles.length === 0) inspector.show("console"); + else if (inspector.active === "console") inspector.show("overview"); + }; + + return { + begin() { + streams.replaceChildren(); + streams.hidden = true; + setText(notice, ""); + pane.setAttribute("aria-busy", "true"); + }, + stream(stream: OutputStream, text: string) { + streams.append(h("span", { class: `console-${stream}` }, text)); + streams.hidden = false; + }, + finish(run: RunResult, action: "run" | "inspect") { + pane.setAttribute("aria-busy", "false"); + if (run.kind === "timeout") { + setText(error, `Stopped after ${run.limitMs / 1000} seconds. Python restarted, so variables and imports were reset.`); + setText(result, ""); + inspector.setBundles([], "The run timed out before finishing."); + inspector.show("console"); + } else if (run.kind === "cancelled") { + setText(error, ""); + setText(message, "Stopped. Python restarted, so variables and imports were reset."); + inspector.setBundles([], "The run was stopped."); + } else if (run.kind === "failed") { + showFailure(run.message); + } else if (run.kind === "unavailable") { + setText(notice, "Python is not ready yet."); + } else { + const seconds = (run.durationMs / 1000).toFixed(1); + const label = action === "inspect" ? `Inspected in ${seconds} s with mlbundle.inspect.` : `Finished in ${seconds} s.`; + if (run.report.outcome.kind === "none" && action === "run") setText(result, ""); + showReport(run.report, label); + } + }, + /** A startup failure stays visible until Python boots again. */ + showFailure, + showBooting(text: string) { + if (bootFailed) { + bootFailed = false; + setText(error, ""); + } + setText(notice, `${text} The first visit downloads Python, scikit-learn and pandas; later visits use your browser cache.`); + }, + showReady() { + if (!bootFailed) setText(notice, ""); + }, + }; +}; diff --git a/playground/src/workspace/split.ts b/playground/src/workspace/split.ts new file mode 100644 index 0000000..026f6b5 --- /dev/null +++ b/playground/src/workspace/split.ts @@ -0,0 +1,90 @@ +import { clampRatio, type CodePosition } from "./state.ts"; + +export const bindCodeSplit = ( + split: HTMLElement, + separator: HTMLElement, + initialPosition: CodePosition, + initialRatio: number, + onChange: (position: CodePosition, ratio: number) => void, +) => { + let position = initialPosition; + let ratio = initialRatio; + let pointer: number | null = null; + const horizontal = () => position === "left" || position === "right"; + const reversed = () => position === "right" || position === "bottom"; + const update = () => { + ratio = clampRatio(ratio); + split.dataset.position = position; + split.style.setProperty("--code-share", `${ratio}fr`); + split.style.setProperty("--output-share", `${100 - ratio}fr`); + separator.setAttribute("aria-orientation", horizontal() ? "vertical" : "horizontal"); + separator.setAttribute("aria-valuenow", String(Math.round(ratio))); + separator.setAttribute("aria-valuetext", `Code: ${Math.round(ratio)} %`); + }; + const move = (event: PointerEvent) => { + if (pointer !== event.pointerId) return; + const bounds = split.getBoundingClientRect(); + const offset = horizontal() ? event.clientX - bounds.left : event.clientY - bounds.top; + const size = horizontal() ? bounds.width : bounds.height; + const divider = horizontal() ? separator.offsetWidth : separator.offsetHeight; + ratio = ((offset - divider / 2) / Math.max(1, size - divider)) * 100; + if (reversed()) ratio = 100 - ratio; + update(); + }; + const end = () => { + if (pointer === null) return; + pointer = null; + split.classList.remove("is-resizing"); + onChange(position, ratio); + }; + const start = (event: PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + pointer = event.pointerId; + separator.focus(); + separator.setPointerCapture(pointer); + split.classList.add("is-resizing"); + }; + const keydown = (event: KeyboardEvent) => { + const less = horizontal() ? "ArrowLeft" : "ArrowUp"; + const more = horizontal() ? "ArrowRight" : "ArrowDown"; + if (![less, more, "Home", "End", "Enter"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "Home") ratio = 20; + else if (event.key === "End") ratio = 80; + else if (event.key === "Enter") ratio = 50; + else ratio += (event.key === more ? 1 : -1) * (reversed() ? -1 : 1) * (event.shiftKey ? 10 : 2); + update(); + onChange(position, ratio); + }; + const reset = () => { + ratio = 50; + update(); + onChange(position, ratio); + }; + separator.addEventListener("pointerdown", start); + separator.addEventListener("pointermove", move); + separator.addEventListener("pointerup", end); + separator.addEventListener("pointercancel", end); + separator.addEventListener("lostpointercapture", end); + separator.addEventListener("keydown", keydown); + separator.addEventListener("dblclick", reset); + update(); + return { + setPosition(next: CodePosition, nextRatio = ratio) { + position = next; + ratio = nextRatio; + update(); + onChange(position, ratio); + }, + destroy() { + separator.removeEventListener("pointerdown", start); + separator.removeEventListener("pointermove", move); + separator.removeEventListener("pointerup", end); + separator.removeEventListener("pointercancel", end); + separator.removeEventListener("lostpointercapture", end); + separator.removeEventListener("keydown", keydown); + separator.removeEventListener("dblclick", reset); + }, + }; +}; diff --git a/playground/src/workspace/state.ts b/playground/src/workspace/state.ts new file mode 100644 index 0000000..f40e9e3 --- /dev/null +++ b/playground/src/workspace/state.ts @@ -0,0 +1,50 @@ +export type CodePosition = "left" | "right" | "top" | "bottom"; +export interface WorkspaceState { + code: string; + exampleId: string; + position: CodePosition; + ratio: number; +} + +const STORAGE_KEY = "mlbundle-playground-workspace"; +export const isCodePosition = (value: unknown): value is CodePosition => + value === "left" || value === "right" || value === "top" || value === "bottom"; + +export const clampRatio = (ratio: number) => Math.max(20, Math.min(80, ratio)); + +export const readWorkspaceState = ( + storage: Pick | undefined, + fallback: WorkspaceState, +): WorkspaceState => { + try { + const saved: unknown = JSON.parse(storage?.getItem(STORAGE_KEY) ?? "null"); + if (!saved || typeof saved !== "object") return fallback; + return { + code: "code" in saved && typeof saved.code === "string" ? saved.code : fallback.code, + exampleId: + "exampleId" in saved && typeof saved.exampleId === "string" + ? saved.exampleId + : fallback.exampleId, + position: + "position" in saved && isCodePosition(saved.position) ? saved.position : fallback.position, + ratio: + "ratio" in saved && typeof saved.ratio === "number" && Number.isFinite(saved.ratio) + ? clampRatio(saved.ratio) + : fallback.ratio, + }; + } catch { + return fallback; + } +}; + +export const saveWorkspaceState = ( + storage: Pick | undefined, + state: WorkspaceState, +): boolean => { + try { + storage?.setItem(STORAGE_KEY, JSON.stringify(state)); + return storage !== undefined; + } catch { + return false; + } +}; diff --git a/playground/src/workspace/status.ts b/playground/src/workspace/status.ts new file mode 100644 index 0000000..dae56aa --- /dev/null +++ b/playground/src/workspace/status.ts @@ -0,0 +1,15 @@ +import { icon, type Icon } from "../icons.ts"; + +export const createStatus = (element: HTMLElement) => { + let label = ""; + return { + get label() { return label; }, + show(text: string, symbol: Icon = "ready") { + label = text; + element.innerHTML = `${icon(symbol)}`; + element.querySelector("span")!.textContent = text; + element.dataset.tooltip = text; + element.dataset.symbol = symbol; + }, + }; +}; diff --git a/playground/src/workspace/viewer.ts b/playground/src/workspace/viewer.ts new file mode 100644 index 0000000..24f558d --- /dev/null +++ b/playground/src/workspace/viewer.ts @@ -0,0 +1,29 @@ +import { json } from "@codemirror/lang-json"; +import { EditorState } from "@codemirror/state"; +import { EditorView, lineNumbers } from "@codemirror/view"; +import { codeHighlighting } from "./highlight.ts"; + +/** Read-only, focusable JSON view. */ +export const createJsonViewer = (parent: HTMLElement, label: string) => { + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: "", + extensions: [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + lineNumbers(), + json(), + codeHighlighting, + EditorView.contentAttributes.of({ "aria-label": label, tabindex: "0" }), + ], + }), + }); + return { + show(text: string) { + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }); + view.scrollDOM.scrollTop = 0; + }, + text: () => view.state.doc.toString(), + }; +}; diff --git a/playground/tests/e2e/playground.spec.ts b/playground/tests/e2e/playground.spec.ts new file mode 100644 index 0000000..27f3344 --- /dev/null +++ b/playground/tests/e2e/playground.spec.ts @@ -0,0 +1,100 @@ +import { readFile } from "node:fs/promises"; +import { expect, test, type FrameLocator, type Page } from "@playwright/test"; + +const frame = (page: Page) => page.frameLocator('iframe[title="Python editor"]'); +const editor = (ws: FrameLocator) => ws.getByRole("textbox", { name: "Python code" }); +const tab = (ws: FrameLocator, name: string) => ws.getByRole("tab", { name, exact: true }); +const panel = (ws: FrameLocator) => ws.getByRole("tabpanel"); +const chip = (ws: FrameLocator) => ws.locator(".inspector-chip"); + +const replaceCode = async (page: Page, code: string) => { + await editor(frame(page)).click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("Delete"); + // insertText keeps indentation exactly; typing would trigger auto-indent. + await page.keyboard.insertText(code); +}; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => localStorage.clear()); + await page.reload(); +}); + +test("exports, verifies and visualizes the classifier example", async ({ page }) => { + const ws = frame(page); + await expect(chip(ws)).toHaveText("Verified"); + await expect(page).toHaveTitle("MLbundle Playground"); + await expect(page.getByRole("link", { name: "MLbundle home" })).toBeVisible(); + await expect(ws.locator(".inspector-title")).toContainText("classifier.mlbundle"); + await expect(panel(ws)).toContainText("DecisionTreeClassifier"); + await expect(panel(ws)).toContainText("Emscripten"); + // Narrow screens start with the code stacked above the inspector. + const narrow = page.viewportSize()!.width < 720; + await expect(ws.locator(".code-split")).toHaveAttribute("data-position", narrow ? "top" : "left"); + await expect(ws.getByRole("combobox", { name: "Bundle" })).toBeHidden(); + + await tab(ws, "Structure").click(); + await expect(panel(ws)).toContainText("Family: decision_tree"); + await expect(ws.getByRole("list", { name: "Decision tree" })).toContainText("≤"); + // Arrow keys move between tabs, as in the ARIA tabs pattern. + await tab(ws, "Structure").press("ArrowRight"); + await expect(tab(ws, "Members")).toBeFocused(); + await expect(panel(ws)).toContainText("model.skops"); + await tab(ws, "Members").press("ArrowRight"); + await expect(panel(ws)).toContainText("sample_rows=N"); + await tab(ws, "Samples").press("ArrowLeft"); + await tab(ws, "Members").press("Home"); + await expect(tab(ws, "Overview")).toHaveAttribute("aria-selected", "true"); + await tab(ws, "Schema").click(); + await expect(panel(ws)).toContainText("Predicted class"); + await tab(ws, "Manifest").click(); + await expect(ws.getByRole("textbox", { name: "Manifest JSON" })).toContainText('"format_name": "mlbundle"'); +}); + +test("downloads the written bundle and inspects uploads without loading them", async ({ page }) => { + const ws = frame(page); + await expect(chip(ws)).toHaveText("Verified"); + const [download] = await Promise.all([ + page.waitForEvent("download"), + ws.getByRole("button", { name: "Download .mlbundle" }).click(), + ]); + expect(download.suggestedFilename()).toBe("classifier.mlbundle"); + const bytes = await readFile((await download.path())!); + expect([...bytes.subarray(0, 2)]).toEqual([0x50, 0x4b]); + + const input = ws.locator('input[type="file"]'); + await input.setInputFiles({ name: "uploaded.mlbundle", mimeType: "application/zip", buffer: bytes }); + await expect(chip(ws)).toHaveText("Inspected only"); + await expect(ws.locator(".inspector-title")).toContainText("uploaded.mlbundle"); + await expect(ws.getByRole("button", { name: "Download .mlbundle" })).toBeHidden(); + await tab(ws, "Structure").click(); + await expect(panel(ws)).toContainText("without deserializing the model"); + + await input.setInputFiles({ name: "notes.mlbundle", mimeType: "application/zip", buffer: Buffer.from("hello") }); + await expect(chip(ws)).toHaveText("Rejected"); + await tab(ws, "Overview").click(); + await expect(panel(ws)).toContainText("InvalidContainerError"); +}); + +test("runs edited code, reports errors and keeps examples resettable", async ({ page }) => { + const ws = frame(page); + await expect(chip(ws)).toHaveText("Verified"); + await replaceCode(page, "from mlbundle import export\nprint('about to fail')\nexport(object(), 'x.mlbundle')"); + await page.keyboard.press("ControlOrMeta+Enter"); + await expect(tab(ws, "Console")).toHaveAttribute("aria-selected", "true"); + await expect(panel(ws)).toContainText("UnsupportedModelError"); + await expect(panel(ws)).toContainText("about to fail"); + + await ws.getByRole("combobox", { name: "Example" }).selectOption("regressor"); + await expect(ws.locator(".inspector-title")).toContainText("regressor.mlbundle"); + await tab(ws, "Structure").click(); + await expect(ws.getByRole("combobox", { name: "Tree" })).toHaveValue("0"); + await ws.getByRole("combobox", { name: "Tree" }).selectOption("2"); + await expect(panel(ws)).toContainText("Feature importances"); + + await replaceCode(page, "1 + 1"); + await ws.getByRole("button", { name: "Reset example" }).click(); + await expect(editor(ws)).toContainText("RandomForestRegressor"); + await expect(chip(ws)).toHaveText("Verified"); +}); diff --git a/playground/tests/runtime/examples.test.ts b/playground/tests/runtime/examples.test.ts new file mode 100644 index 0000000..23b4045 --- /dev/null +++ b/playground/tests/runtime/examples.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { before, test } from "node:test"; +import type { BundleReport, Json, RunReport } from "../../src/runtime/protocol.ts"; +import { projectVersion, type PythonSession } from "../../src/runtime/session.ts"; +import { bootNodeSession, examplesDir, readExample, repoRoot } from "./load.ts"; + +type Obj = { [key: string]: Json }; +let session: PythonSession; +before(async () => { + session = await bootNodeSession(); +}); + +const runExample = async (file: string): Promise => { + const report = await session.run(readExample(file)); + assert.notEqual(report.outcome.kind, "error", JSON.stringify(report.outcome)); + return report; +}; +const only = (report: RunReport): BundleReport => { + assert.equal(report.bundles.length, 1); + const bundle = report.bundles[0]!; + assert.equal(bundle.status, "verified", bundle.error ?? ""); + assert.equal(bundle.source, "generated"); + return bundle; +}; +const value = (report: RunReport): unknown => { + assert.equal(report.outcome.kind, "json", JSON.stringify(report.outcome)); + return JSON.parse((report.outcome as { text: string }).text); +}; +const model = (bundle: BundleReport) => bundle.manifest!.model as Obj; +const names = (bundle: BundleReport) => bundle.members!.map((member) => member.name); + +test("the runtime runs the repository's mlbundle on the pinned wheels", () => { + assert.equal(session.versions.python.split(".").slice(0, 2).join("."), "3.14"); + assert.equal(session.versions.mlbundle, projectVersion(readFileSync(join(repoRoot, "pyproject.toml"), "utf8"))); + assert.equal(session.versions.mlschema, "0.3.0"); + assert.equal(session.versions.skops, "0.16.0"); + assert.match(session.versions["scikit-learn"], /^1\.8\./); +}); + +test("the example catalog is complete", () => { + const files = readdirSync(examplesDir).filter((file) => file.endsWith(".py")); + assert.deepEqual(files.sort(), [ + "classifier.py", + "errors.py", + "pipeline.py", + "regressor.py", + "samples.py", + ]); +}); + +test("the classifier example writes a verified bundle with a tree", async () => { + const report = await runExample("classifier.py"); + const bundle = only(report); + assert.deepEqual(value(report), [["cold", "warm"]]); + assert.equal(model(bundle).estimator_class, "DecisionTreeClassifier"); + assert.equal(model(bundle).kind, "classifier"); + assert.deepEqual(names(bundle), ["manifest.json", "model.skops", "schema.json", "structure.json"]); + for (const member of bundle.members!.slice(1)) assert.match(member.sha256 ?? "", /^[0-9a-f]{64}$/); + assert.equal(bundle.structure!.family, "decision_tree"); + assert.equal(bundle.samples, null); + // The download is the archive mlbundle wrote: a ZIP of the reported size. + assert.equal(bundle.bytes!.length, bundle.sizeBytes); + assert.deepEqual([...bundle.bytes!.subarray(0, 4)], [0x50, 0x4b, 0x03, 0x04]); + const environment = bundle.manifest!.environment as Obj; + assert.equal(environment.os, "Emscripten"); + assert.equal((environment.packages as Obj)["scikit-learn"], session.versions["scikit-learn"]); +}); + +test("the regressor example records one tree per estimator", async () => { + const bundle = only(await runExample("regressor.py")); + assert.equal(model(bundle).kind, "regressor"); + assert.equal(bundle.structure!.family, "tree_ensemble"); + assert.equal((bundle.structure!.trees as Json[]).length, 3); + assert.deepEqual((bundle.manifest!.features as Obj).names, ["area_m2", "rooms", "floor"]); +}); + +test("samples appear only when requested and one-hot columns form one field", async () => { + const report = await runExample("samples.py"); + const bundle = only(report); + assert.deepEqual(value(report), ["number", "onehot-category", "boolean"]); + assert.equal(bundle.samples!.length, 3); + assert.ok(names(bundle).includes("samples.json")); + assert.equal(bundle.structure!.family, "linear"); +}); + +test("an all-sklearn pipeline is verified with null structure", async () => { + const bundle = only(await runExample("pipeline.py")); + assert.equal(bundle.structure, null); + assert.ok(!names(bundle).includes("structure.json")); + assert.equal((bundle.schema as Obj).source, "default"); +}); + +test("rejections happen at export and write nothing", async () => { + const report = await runExample("errors.py"); + assert.equal(report.bundles.length, 0); + const results = value(report) as Obj[]; + assert.deepEqual( + results.map((entry) => [entry.case, entry.error]), + [ + ["custom subclass", "UnsupportedModelError"], + ["HistGradientBoosting", "UnsupportedModelError"], + ["samples without a frame", "InvalidSamplesError"], + ], + ); +}); + +test("an uploaded bundle is inspected without loading its model", async () => { + const generated = only(await runExample("classifier.py")); + const report = await session.inspect("my model.mlbundle", generated.bytes!.slice()); + const bundle = report.bundles[0]!; + assert.equal(bundle.status, "inspected"); + assert.equal(bundle.source, "uploaded"); + assert.equal(bundle.fileName, "my model.mlbundle"); + assert.equal(bundle.structure, null); + assert.deepEqual(bundle.manifest, generated.manifest); + assert.deepEqual(bundle.schema, generated.schema); + assert.equal(bundle.bytes, undefined); +}); + +test("damaged and non-bundle uploads are rejected by mlbundle.inspect", async () => { + const generated = only(await runExample("classifier.py")); + const tampered = generated.bytes!.slice(); + const middle = Math.floor(tampered.length / 2); + tampered[middle] = tampered[middle]! ^ 0xff; + const damaged = (await session.inspect("damaged.mlbundle", tampered)).bundles[0]!; + assert.equal(damaged.status, "rejected"); + assert.match(damaged.error!, /Error: /); + const notes = new TextEncoder().encode("hello"); + const text = (await session.inspect("notes.mlbundle", notes)).bundles[0]!; + assert.equal(text.status, "rejected"); + assert.match(text.error!, /^InvalidContainerError/); +}); + +test("user edits run as written in a fresh namespace and directory", async () => { + const failed = await session.run("from mlbundle import export\nexport(object(), 'x.mlbundle')"); + assert.equal(failed.outcome.kind, "error"); + const traceback = (failed.outcome as { text: string }).text; + assert.match(traceback, /File "", line 2[\s\S]*UnsupportedModelError/); + assert.equal(failed.bundles.length, 0); + + const stray = await session.run("open('stray.mlbundle', 'wb').write(b'not a zip')\nleftover = 1"); + assert.equal(stray.bundles[0]!.status, "rejected"); + assert.equal(stray.bundles[0]!.bytes, undefined); + const next = await session.run("import os\nsorted(os.listdir('.'))"); + assert.deepEqual(next.outcome, { kind: "json", text: "[]" }); + assert.deepEqual(next.bundles, []); + const name = await session.run("leftover"); + assert.match((name.outcome as { text: string }).text, /NameError/); +}); diff --git a/playground/tests/runtime/load.ts b/playground/tests/runtime/load.ts new file mode 100644 index 0000000..a8cf690 --- /dev/null +++ b/playground/tests/runtime/load.ts @@ -0,0 +1,40 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPyodide } from "pyodide"; +import { startSession, type PythonSession, type SourceTree } from "../../src/runtime/session.ts"; + +const here = fileURLToPath(new URL(".", import.meta.url)); +export const repoRoot = join(here, "../../.."); +export const examplesDir = join(here, "../../src/examples"); + +const packageFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return entry.name === "__pycache__" ? [] : packageFiles(path); + return /\.(py|json)$/.test(entry.name) ? [path] : []; + }); + +/** Same tree the Vite build bundles: every .py and .json file under src/mlbundle. */ +export const readSources = (): SourceTree => { + const srcRoot = join(repoRoot, "src"); + return Object.fromEntries( + packageFiles(join(srcRoot, "mlbundle")).map((path) => [ + relative(srcRoot, path).replaceAll("\\", "/"), + readFileSync(path, "utf8"), + ]), + ); +}; + +export const readExample = (file: string) => readFileSync(join(examplesDir, file), "utf8"); + +/** Boots real Pyodide with the pinned wheels; downloads from jsDelivr and PyPI. */ +export const bootNodeSession = async (): Promise => { + const pyodide = await loadPyodide({ stdout: () => {}, stderr: () => {} }); + return startSession( + pyodide, + readSources(), + readFileSync(join(repoRoot, "pyproject.toml"), "utf8"), + readFileSync(join(here, "../../src/runtime/harness.py"), "utf8"), + ); +}; diff --git a/playground/tests/unit/client.test.ts b/playground/tests/unit/client.test.ts new file mode 100644 index 0000000..9a67172 --- /dev/null +++ b/playground/tests/unit/client.test.ts @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + createPythonRuntime, + type RuntimeState, + type WorkerLike, +} from "../../src/runtime/client.ts"; +import type { FromWorker, RunReport, ToWorker } from "../../src/runtime/protocol.ts"; + +const VERSIONS = { + python: "3.14.2", + mlbundle: "0.2.0", + mlschema: "0.3.0", + "scikit-learn": "1.8.0", + skops: "0.16.0", + pandas: "3.0.2", + numpy: "2.4.6", +}; +const NONE: RunReport = { outcome: { kind: "none" }, bundles: [], skipped: 0 }; +const JSON_REPORT: RunReport = { outcome: { kind: "json", text: "[]" }, bundles: [], skipped: 0 }; +const LIMITS = { bootMs: 40, runMs: 40 }; + +class FakeWorker implements WorkerLike { + onmessage: WorkerLike["onmessage"] = null; + onerror: WorkerLike["onerror"] = null; + sent: ToWorker[] = []; + transfers: Transferable[][] = []; + terminated = false; + failPost = false; + postMessage(message: ToWorker, transfer: Transferable[] = []) { + if (this.failPost) throw new Error("could not clone message"); + this.sent.push(message); + this.transfers.push(transfer); + } + terminate() { + this.terminated = true; + } + emit(data: FromWorker) { + this.onmessage?.({ data } as MessageEvent); + } + crash(message: string) { + this.onerror?.({ message, preventDefault() {} } as ErrorEvent); + } +} + +const setup = (limits = LIMITS) => { + const workers: FakeWorker[] = []; + const states: RuntimeState[] = []; + const streams: string[] = []; + const runtime = createPythonRuntime( + () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker; + }, + { onState: (state) => states.push(state), onStream: (_, text) => streams.push(text) }, + limits, + ); + const latest = () => workers.at(-1)!; + return { runtime, workers, states, streams, latest }; +}; + +const ready = () => { + const context = setup(); + context.latest().emit({ type: "ready", versions: VERSIONS }); + return context; +}; + +test("boots, reports progress and becomes ready", () => { + const { runtime, latest, states } = setup(); + latest().emit({ type: "progress", message: "Loading scikit-learn and pandas…" }); + latest().emit({ type: "ready", versions: VERSIONS }); + assert.deepEqual( + states.map((state) => state.status), + ["booting", "booting", "ready"], + ); + assert.deepEqual(runtime.state, { status: "ready", versions: VERSIONS }); + runtime.dispose(); +}); + +test("runs code, forwards its streams and ignores stale run ids", async () => { + const { runtime, latest, streams } = ready(); + const result = runtime.run("print(1)"); + assert.equal(runtime.state.status, "running"); + const { id } = latest().sent[0]!; + latest().emit({ type: "stream", id: id + 1, stream: "stdout", text: "stale\n" }); + latest().emit({ type: "stream", id, stream: "stdout", text: "1\n" }); + latest().emit({ type: "done", id: id + 1, report: NONE, durationMs: 1 }); + latest().emit({ type: "done", id, report: JSON_REPORT, durationMs: 3 }); + assert.deepEqual(await result, { kind: "finished", report: JSON_REPORT, durationMs: 3 }); + assert.deepEqual(latest().sent[0], { type: "run", id, code: "print(1)" }); + assert.deepEqual(streams, ["1\n"]); + assert.equal(runtime.state.status, "ready"); + runtime.dispose(); +}); + +test("inspects an upload by transferring its bytes and returns the report", async () => { + const { runtime, latest } = ready(); + const data = new Uint8Array([0x50, 0x4b, 3, 4]).buffer; + const result = runtime.inspect("model.mlbundle", data); + assert.equal(runtime.state.status, "running"); + const sent = latest().sent[0]!; + assert.equal(sent.type, "inspect"); + assert.equal(sent.type === "inspect" && sent.name, "model.mlbundle"); + assert.deepEqual(latest().transfers[0], [data], "the buffer is transferred, not copied"); + const report: RunReport = { + outcome: { kind: "none" }, + skipped: 0, + bundles: [ + { fileName: "model.mlbundle", sizeBytes: 4, source: "uploaded", status: "rejected", error: "InvalidContainerError: x" }, + ], + }; + latest().emit({ type: "done", id: sent.id, report, durationMs: 2 }); + assert.deepEqual(await result, { kind: "finished", report, durationMs: 2 }); + assert.equal(runtime.state.status, "ready"); + const blocked = runtime.inspect("again.mlbundle", new ArrayBuffer(1)); + assert.deepEqual(await runtime.run("1"), { kind: "unavailable" }, "one request at a time"); + latest().emit({ type: "done", id: latest().sent[1]!.id, report: NONE, durationMs: 1 }); + assert.equal((await blocked).kind, "finished"); + runtime.dispose(); +}); + +test("refuses to run before Python is ready", async () => { + const { runtime, latest } = setup(); + assert.deepEqual(await runtime.run("1"), { kind: "unavailable" }); + assert.equal(latest().sent.length, 0); + runtime.dispose(); +}); + +test("a run past the time limit terminates the worker and boots a new one", async () => { + const { runtime, workers } = ready(); + assert.deepEqual(await runtime.run("while True: pass"), { kind: "timeout", limitMs: 40 }); + assert.equal(workers[0]!.terminated, true); + assert.equal(workers.length, 2); + assert.equal(runtime.state.status, "booting"); + runtime.dispose(); +}); + +test("cancel stops the run by restarting Python", async () => { + const { runtime, workers } = ready(); + const result = runtime.run("while True: pass"); + runtime.cancel(); + assert.deepEqual(await result, { kind: "cancelled" }); + assert.equal(workers[0]!.terminated, true); + assert.equal(workers.length, 2); + runtime.cancel(); + assert.equal(workers.length, 2, "cancel without a run does nothing"); + runtime.dispose(); +}); + +test("a worker crash during a run settles it as failed at once", async () => { + const { runtime, workers, latest } = ready(); + const result = runtime.run("1"); + latest().crash("out of memory"); + const message = "Python stopped unexpectedly: out of memory"; + assert.deepEqual(await result, { kind: "failed", message }); + assert.deepEqual(runtime.state, { status: "failed", message }); + assert.equal(workers[0]!.terminated, true); + await new Promise((resolve) => setTimeout(resolve, LIMITS.runMs * 2)); + assert.equal(workers.length, 1, "no restart until the visitor asks"); + runtime.restart(); + assert.equal(workers.length, 2); + runtime.dispose(); +}); + +test("boot errors and boot timeouts leave the runtime failed", async () => { + const errored = setup(); + errored.latest().emit({ type: "boot-error", message: "network down" }); + assert.deepEqual(errored.runtime.state, { + status: "failed", + message: "Python could not start: network down", + }); + const slow = setup(); + await new Promise((resolve) => setTimeout(resolve, LIMITS.bootMs * 2)); + assert.deepEqual(slow.runtime.state, { + status: "failed", + message: "Python could not start: no response within 0.04 seconds.", + }); + assert.equal(slow.latest().terminated, true); +}); + +test("a worker that cannot be created or messaged fails explicitly", async () => { + const runtime = createPythonRuntime( + () => { + throw new Error("Worker is not supported"); + }, + { onState() {}, onStream() {} }, + LIMITS, + ); + assert.deepEqual(runtime.state, { + status: "failed", + message: "Python could not start: Worker is not supported", + }); + assert.deepEqual(await runtime.run("1"), { kind: "unavailable" }); + + const { runtime: posting, latest } = ready(); + latest().failPost = true; + assert.deepEqual(await posting.run("1"), { + kind: "failed", + message: "Python stopped unexpectedly: could not clone message", + }); + assert.equal(posting.state.status, "failed"); +}); diff --git a/playground/tests/unit/config.test.ts b/playground/tests/unit/config.test.ts new file mode 100644 index 0000000..18b1e5f --- /dev/null +++ b/playground/tests/unit/config.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { projectVersion } from "../../src/runtime/session.ts"; +import { PYODIDE_VERSION, PYPI_PINS } from "../../src/runtime/versions.ts"; +import { readWorkspaceState, saveWorkspaceState } from "../../src/workspace/state.ts"; + +const FALLBACK = { code: "x", exampleId: "classifier", position: "left", ratio: 50 } as const; +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); + +test("the CDN loader version matches the installed pyodide package", () => { + const installed = JSON.parse(read("../../node_modules/pyodide/package.json")) as { version: string }; + assert.equal(PYODIDE_VERSION, installed.version); +}); + +test("pure-Python pins match the repository lock", () => { + const lock = read("../../../uv.lock"); + for (const pin of PYPI_PINS) { + const [name, version] = pin.split("==") as [string, string]; + assert.match(lock, new RegExp(`name = "${name}"\nversion = "${version.replaceAll(".", "\\.")}"`), pin); + } +}); + +test("the bundled package reports the version pyproject.toml declares", () => { + const pyproject = read("../../../pyproject.toml"); + const declared = /^version = "([^"]+)"/m.exec(pyproject)![1]; + assert.equal(projectVersion(pyproject), declared); + assert.equal(projectVersion('[tool.x]\nversion = "9"\n[project]\nname = "a"\nversion = "1.2"\n'), "1.2"); + assert.throws(() => projectVersion('[tool.x]\nversion = "9"\n'), /no \[project\] version/); +}); + +test("workspace state restores valid fields and falls back on invalid ones", () => { + const storage = new Map(); + const backend = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => void storage.set(key, value), + }; + assert.deepEqual(readWorkspaceState(backend, FALLBACK), FALLBACK); + assert.equal( + saveWorkspaceState(backend, { code: "y", exampleId: "samples", position: "top", ratio: 95 }), + true, + ); + assert.deepEqual(readWorkspaceState(backend, FALLBACK), { + code: "y", + exampleId: "samples", + position: "top", + ratio: 80, + }); + assert.equal([...storage.keys()][0], "mlbundle-playground-workspace"); + storage.set([...storage.keys()][0]!, '{"position":"diagonal","ratio":"wide"}'); + assert.deepEqual(readWorkspaceState(backend, FALLBACK), FALLBACK); + storage.set([...storage.keys()][0]!, "{not json"); + assert.deepEqual(readWorkspaceState(backend, FALLBACK), FALLBACK); +}); + +test("saving reports unavailable storage", () => { + const full = { + setItem() { + throw new Error("QuotaExceededError"); + }, + }; + assert.equal(saveWorkspaceState(full, FALLBACK), false); + assert.equal(saveWorkspaceState(undefined, FALLBACK), false); +}); diff --git a/playground/tests/unit/settings.test.ts b/playground/tests/unit/settings.test.ts new file mode 100644 index 0000000..80135f0 --- /dev/null +++ b/playground/tests/unit/settings.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + APPEARANCES, + DEFAULT_SETTINGS, + readSettings, + saveSettings, + SETTINGS_KEY, + STYLES, + THEMES, + VIEWPORTS, + viewportSize, + type Settings, +} from "../../src/settings.ts"; + +const memory = (initial?: string) => { + const items = new Map(initial === undefined ? [] : [[SETTINGS_KEY, initial]]); + return { + items, + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => void items.set(key, value), + }; +}; +const CHOSEN: Settings = { + theme: "cobalt", + style: "contrast", + appearance: "dark", + viewport: "custom", + width: 500, + height: 700, +}; + +test("catalogs offer MLForm's choices with unique ids", () => { + assert.deepEqual( + THEMES.map(({ id }) => id), + ["eshkra", "m3diseen", "dosetailor", "clickhouse", "airbnb", "neutral", "cobalt", "sage", "sunset", "graphite"], + ); + assert.deepEqual(STYLES.map(({ label }) => label), ["Default", "Minimal", "Soft", "Contrast"]); + assert.deepEqual(APPEARANCES.map(({ label }) => label), ["System", "Light", "Dark"]); + for (const items of [THEMES, STYLES, APPEARANCES, VIEWPORTS]) { + assert.equal(new Set(items.map(({ id }) => id)).size, items.length); + } +}); + +test("saved settings round-trip", () => { + const storage = memory(); + assert.deepEqual(readSettings(storage), DEFAULT_SETTINGS); + assert.equal(saveSettings(storage, CHOSEN), true); + assert.deepEqual(readSettings(storage), CHOSEN); +}); + +test("each invalid field falls back alone", () => { + const saved = { ...CHOSEN, theme: "unknown", style: 3, viewport: "watch", width: 0 }; + assert.deepEqual(readSettings(memory(JSON.stringify(saved))), { + ...CHOSEN, + theme: DEFAULT_SETTINGS.theme, + style: DEFAULT_SETTINGS.style, + viewport: DEFAULT_SETTINGS.viewport, + width: DEFAULT_SETTINGS.width, + }); + for (const size of [8193, 1.5, -1, "640", null]) { + assert.equal(readSettings(memory(JSON.stringify({ height: size }))).height, DEFAULT_SETTINGS.height); + } + assert.equal(readSettings(memory(JSON.stringify({ width: 8192 }))).width, 8192); + assert.equal(readSettings(memory(JSON.stringify({ appearance: "sepia" }))).appearance, "auto"); +}); + +test("unreadable storage yields the defaults", () => { + for (const raw of ["{not json", "[1, 2]", "42", "null"]) { + assert.deepEqual(readSettings(memory(raw)), DEFAULT_SETTINGS); + } + const blocked = { + getItem(): string { + throw new Error("SecurityError"); + }, + }; + assert.deepEqual(readSettings(blocked), DEFAULT_SETTINGS); + assert.deepEqual(readSettings(undefined), DEFAULT_SETTINGS); +}); + +test("saving reports unavailable storage", () => { + const full = { + setItem() { + throw new Error("QuotaExceededError"); + }, + }; + assert.equal(saveSettings(full, CHOSEN), false); + assert.equal(saveSettings(undefined, CHOSEN), false); +}); + +test("viewport size follows the preset or the custom size", () => { + assert.equal(viewportSize(DEFAULT_SETTINGS), null); + assert.deepEqual(viewportSize({ ...CHOSEN, viewport: "phone" }), { + width: 390, + height: 844, + name: "Phone", + }); + assert.deepEqual(viewportSize({ ...CHOSEN, viewport: "ultrawide" }), { + width: 2560, + height: 1080, + name: "Ultrawide", + }); + assert.deepEqual(viewportSize(CHOSEN), { width: 500, height: 700, name: "Custom" }); +}); diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000..0dc1a5f --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker.ImportScripts"], + "types": ["node", "vite/client"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "*.ts"] +} diff --git a/playground/vite.config.ts b/playground/vite.config.ts new file mode 100644 index 0000000..74e0eed --- /dev/null +++ b/playground/vite.config.ts @@ -0,0 +1,11 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + +export default defineConfig({ + // The worker bundles ../src/mlbundle and ../pyproject.toml; the header uses ../brand. + server: { fs: { allow: [repoRoot] } }, + worker: { format: "es" }, + build: { target: "es2022" }, +}); diff --git a/playground/wrangler.jsonc b/playground/wrangler.jsonc new file mode 100644 index 0000000..e428612 --- /dev/null +++ b/playground/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "mlbundle-playground", + "compatibility_date": "2026-10-19", + "workers_dev": false, + "preview_urls": false, + "assets": { + "directory": "./dist", + }, + "routes": [ + { "pattern": "playground.mlbundle.eshkra.dev", "custom_domain": true }, + ], +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e2d91b2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,6106 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.14.0 + version: 12.14.0 + +packages: + + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + cpu: [x64] + os: [win32] + + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.14.0': + optional: true + + '@pnpm/exe.android-x64@12.14.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.14.0': + optional: true + + '@pnpm/exe.darwin-x64@12.14.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.14.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.14.0': + optional: true + + '@pnpm/exe.linux-arm64@12.14.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.14.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.14.0': + optional: true + + '@pnpm/exe.linux-s390x@12.14.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.14.0': + optional: true + + '@pnpm/exe.linux-x64@12.14.0': + optional: true + + '@pnpm/exe.win32-arm64@12.14.0': + optional: true + + '@pnpm/exe.win32-x64@12.14.0': + optional: true + + pnpm@12.14.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@1.3.0 + +importers: + + .: + devDependencies: + vite-plus: + specifier: 1.3.0 + version: 1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1) + wrangler: + specifier: 4.151.1 + version: 4.151.1(@types/node@26.6.5) + + docs: + dependencies: + '@astrojs/starlight': + specifier: 0.42.1 + version: 0.42.1(astro@7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1))(supports-color@10.2.2)(typescript@6.0.3) + astro: + specifier: 7.3.2 + version: 7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1) + sharp: + specifier: 0.35.5 + version: 0.35.5(@types/node@26.6.5) + devDependencies: + '@astrojs/check': + specifier: 0.9.10 + version: 0.9.10(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10))(prettier@3.9.10)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + + landing: + dependencies: + astro: + specifier: 7.3.2 + version: 7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1) + sharp: + specifier: 0.35.5 + version: 0.35.5(@types/node@26.6.5) + devDependencies: + '@astrojs/check': + specifier: 0.9.10 + version: 0.9.10(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10))(prettier@3.9.10)(typescript@6.0.3) + prettier: + specifier: 3.9.10 + version: 3.9.10 + prettier-plugin-astro: + specifier: 1.1.1 + version: 1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10) + typescript: + specifier: 6.0.3 + version: 6.0.3 + + playground: + dependencies: + '@codemirror/commands': + specifier: 6.11.1 + version: 6.11.1 + '@codemirror/lang-json': + specifier: 6.0.2 + version: 6.0.2 + '@codemirror/lang-python': + specifier: 6.2.1 + version: 6.2.1 + '@codemirror/language': + specifier: 6.13.1 + version: 6.13.1 + '@codemirror/state': + specifier: 6.7.6 + version: 6.7.6 + '@codemirror/view': + specifier: 6.43.14 + version: 6.43.14 + '@fontsource-variable/jetbrains-mono': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/mona-sans': + specifier: 5.3.0 + version: 5.3.0 + '@lezer/highlight': + specifier: 1.2.5 + version: 1.2.5 + devDependencies: + '@playwright/test': + specifier: 1.64.1 + version: 1.64.1 + '@types/node': + specifier: 26.6.5 + version: 26.6.5 + pyodide: + specifier: 314.0.7 + version: 314.0.7 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@1.3.0 + version: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)' + +packages: + + '@astrojs/astro2tsx@0.1.3': + resolution: {integrity: sha512-GMDC7Eo0myBUA/QLBl0u+OcegJ8LiBhccmNZR34KmprQNP8gQUJlWlOOZWUsxNA2rNS6GuPYcGDyLoPnjmn4/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/check@0.9.10': + resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler-binding-android-arm64@0.4.1': + resolution: {integrity: sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + resolution: {integrity: sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + resolution: {integrity: sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + resolution: {integrity: sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + resolution: {integrity: sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + resolution: {integrity: sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + resolution: {integrity: sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1': + resolution: {integrity: sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + resolution: {integrity: sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + resolution: {integrity: sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.4.1': + resolution: {integrity: sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.4.1': + resolution: {integrity: sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A==} + engines: {node: '>=22.12.0'} + + '@astrojs/internal-helpers@0.11.0': + resolution: {integrity: sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==} + + '@astrojs/internal-helpers@0.13.0': + resolution: {integrity: sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==} + + '@astrojs/language-server@2.17.2': + resolution: {integrity: sha512-WzqCBYo0RtKrkPLPMnTz2+UOQPosaFhoG/PvoSyPIgBU0BKeS6qeaYv7Rscq7P1LxJdoN0VosQF2h2TZTsIGHg==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-satteri@0.4.1': + resolution: {integrity: sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==} + + '@astrojs/markdown-satteri@0.4.4': + resolution: {integrity: sha512-Byhe9CAiGFJrhMACKpgN/FPf4lcwapqwFQAB7E1sPELTEJINfBIpgnEYOBTXzr//fvSSNhfp/X3AqN4mUV9p+A==} + + '@astrojs/mdx@8.1.0': + resolution: {integrity: sha512-xzrlXrjpBYvC27MTkP+2NMiJG4Jjl2DcoNc/EF9RNxAp0dwhvXC3b22+7OO4kHJQKfISk2uy38HYp1UxX7JfSg==} + engines: {node: '>=22.22.0'} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + '@astrojs/markdown-satteri': ^0.4.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/prism@4.1.0': + resolution: {integrity: sha512-zvRaL3MKTAXWfVQrHAlYtocRZWXBM6ruF3Bl7Lc7QgFHLRTfwMKo33KczlkvkXDY+qcytKhAWOT9dwUUYLUxAw==} + engines: {node: '>=22.22.0'} + + '@astrojs/sitemap@3.7.4': + resolution: {integrity: sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==} + + '@astrojs/starlight@0.42.1': + resolution: {integrity: sha512-ABS/A0IfbJeMbuhCKVkrAcO3EWxhpeLveFuL28+7+BQje7ZHa9OXZbUoiLXdYUhH7XFaiAQjAg44RiF2uuZPJw==} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261012.1': + resolution: {integrity: sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261012.1': + resolution: {integrity: sha512-2/nVGcKXjNNI+DhxKSepTKvFng9H7aN2WXv6Zu67iqgI6JrxypNt5kTPv4ZHosxrcfyKMci/2RfN7GIrN0BitQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261012.1': + resolution: {integrity: sha512-uMe7ONHnLxStD1Ye0YFHBO36uj4cStv5P53rW+5GVWXFT8BRjqVixlH0IqCAHe1Pmcwyd1+7fVoGde89MZSCJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261012.1': + resolution: {integrity: sha512-I0VMdcXk7ygG5vYDce0ePTBFdnBXC0i9WiBY5mWiv0iTinbfea6/rHqSQ7B1UMpjgrYVs9E0m4bZ9ufsk5GCFA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261012.1': + resolution: {integrity: sha512-c5hQvNx574UEoknaCFvMa/NdLrrYiALNbe6sx1okW8uOiRYWxrFuA8WpXFnBJ5XJypm0TEAHYaUTGv3rRLpY7Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/language@6.13.1': + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} + + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} + + '@codemirror/streamparser@6.0.0': + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} + + '@codemirror/view@6.43.14': + resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expressive-code/core@0.44.2': + resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} + + '@expressive-code/plugin-frames@0.44.2': + resolution: {integrity: sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==} + + '@expressive-code/plugin-shiki@0.44.2': + resolution: {integrity: sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==} + + '@expressive-code/plugin-text-markers@0.44.2': + resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} + + '@fontsource-variable/jetbrains-mono@5.3.0': + resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==} + + '@fontsource-variable/mona-sans@5.3.0': + resolution: {integrity: sha512-PkxRB3KI4z9Gjpk7vVqgO3GMSyQuOIIj4TaRDbvbcDj8SzWwI38jvLTQxJM/cTRorhcT7h8qjcllR+qjRtM/Fg==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@lezer/common@1.5.3': + resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==} + + '@lezer/highlight@1.2.5': + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.11': + resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==} + + '@lezer/python@1.1.19': + resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/runtime@0.154.0': + resolution: {integrity: sha512-cBQNGoh659BNgvA8OC/e4BNDtFkv03dJaYdkp2adgOQNtISNAPQ+nB4S8XvWtNk7Xl+0g+7aa+iRGR25Xycd1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.73.0': + resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.73.0': + resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.73.0': + resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.73.0': + resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.73.0': + resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.73.0': + resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + resolution: {integrity: sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + resolution: {integrity: sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + resolution: {integrity: sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2004': + resolution: {integrity: sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + resolution: {integrity: sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2004': + resolution: {integrity: sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.88.0': + resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.88.0': + resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.88.0': + resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.88.0': + resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.88.0': + resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.88.0': + resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.88.0': + resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.88.0': + resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.88.0': + resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.88.0': + resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.88.0': + resolution: {integrity: sha512-G2xbfWTrbqYRhR/vfeKNMQf/sS3/ti0iKJAAkRxHuXWZH/hne/dyIb9QKk5LuR6cJLARxsL6FMoAYmfspd74MQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@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] + + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@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==} + + '@prettier/parse-srcset@3.1.0': + resolution: {integrity: sha512-FIRv2rZotO9NP/r66taYqD1zICpPiBbrECqjKV/xqNotqDxF7fLzzUeK+1RSRx9Tenk0DajR/GcRmTJ2qtHaKQ==} + + '@shikijs/core@4.5.0': + resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.5.0': + resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.5.0': + resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==} + engines: {node: '>=20'} + + '@shikijs/langs@4.5.0': + resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.5.0': + resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==} + engines: {node: '>=20'} + + '@shikijs/themes@4.5.0': + resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==} + engines: {node: '>=20'} + + '@shikijs/types@4.5.0': + resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.8': + resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/emscripten@1.41.6': + resolution: {integrity: sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + + '@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.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitest/browser-preview@5.0.5': + resolution: {integrity: sha512-ViNg4UlomGSJHtnnr7Txm+55P7/aAMMoS+1WYnWjp5w5tzf7YEUAICcs2sb1uMovEnC8el/8zUfkWZ8+7fPCKA==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/browser@5.0.5': + resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + peerDependenciesMeta: + msw: + optional: true + + '@vitest/pretty-format@5.0.5': + resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==} + + '@vitest/snapshot@5.0.5': + resolution: {integrity: sha512-dHyFElWMQy0wFBGrIaPPeUWfJFh+jAUrv46ImIJGd8CADdKV3mlrAu3FyD0wesdxELFCs9M807e040Kba/XKtA==} + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + '@vitest/ui@5.0.5': + resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/utils@5.0.5': + resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} + + '@voidzero-dev/vite-plus-core@1.3.0': + resolution: {integrity: sha512-Bk/LBwNrxoB8XbGUtGJE3r+U9P4puvdZ6UH36ekygE1J+on/ytwM/yBgQwBnvmX6/MVax+2xxj8wk0N/KkyNeA==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@types/node': ^20.19.0 || >=22.12.0 + '@typescript/native-preview': '*' + '@vitejs/devtools': ^0.7.1 + '@volar/typescript': ~2.4.0 + '@vue/language-core': ~3.2.0 || ~3.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + 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 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: '>=0.5.0' + unrun: '*' + vue-tsc: ~3.2.0 || ~3.3.0 + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@types/node': + optional: true + '@typescript/native-preview': + optional: true + '@vitejs/devtools': + optional: true + '@volar/typescript': + optional: true + '@vue/language-core': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + vue-tsc: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@1.3.0': + resolution: {integrity: sha512-BF4W+UwGxUkHPrH2pZLhafJfkJT20WZIbMzZgZLGQ44LPpZPX8Sb+qtblUnXUkkCzTqad4XtSPgiLKglvZkNQQ==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@1.3.0': + resolution: {integrity: sha512-vLZTk0r+s3e7mycj4SGBbcpK+xyM8a86hXQZtQbJyW7xrGJzsavISy7/m4gGZpWN32Hl4Tmx9V/ffeaoXLkRdg==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-H43Dj9RkEyjOFXxTk3DLTa6hqmHhxUN9vbbqNdwQoc3/TPNhC1zsLZy3qgUFbYGLdS7M9jICCqFzj5GDSYrhIw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-Se2GlvzUZOvFy+5hoQHoG7LFeiplKs+EJwaskayCSLwZAP+e6/LTK7EK+8OcdMpmdsLPtZoTJHkIjSzrQowJwQ==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-moxojk6zmog2SaLE4/cG75acE6X3cjR0/52Ba8OG2ct0r+dv55VSE0kellAuxS6o9VHy7TBhkIpwpFpQ34rnAA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-CykxH008Dy9u20Myki9/UIqq3Dbo1ZMqa098aS6AtWp3QKyEKzSLPB3jKDUklgmu83xiFe5BpPA+33oF0yNY7A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-l61S9b8aeiDpi8wPRfz4HonqRkLpt/OVYs0sCskvlXYSW/L8qfLOtuWH/xpF8xDjMIfBefKejBVfU1UlRax5Tw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-b6Ng8YezA6JA3adQBNA+8Mn26+OX4fw/tsPfnk//5Cx+1XFDZoAxWYXtGzc0yUQPEKnoGtfw/0OUvcibg61yYg==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + '@yuku-core/binding-android-arm64@0.19.0': + resolution: {integrity: sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==} + cpu: [arm64] + os: [android] + + '@yuku-core/binding-darwin-arm64@0.19.0': + resolution: {integrity: sha512-7sNJa6XJhyetnTy64jw3NEi4YgUcezHk1G5GH9FSvXAuC8wIlbaOwT9lgq5qo5sspjrZJQlBR1TWflP49uRpMw==} + cpu: [arm64] + os: [darwin] + + '@yuku-core/binding-darwin-x64@0.19.0': + resolution: {integrity: sha512-pTC7dYEc59isdyizc3nsuyFNBu2LU5wMYZ1be6UMr59PY/AJNtO1aBLWV0MgL/z7C1nv/2sX4TNjGgKLoeevJw==} + cpu: [x64] + os: [darwin] + + '@yuku-core/binding-freebsd-x64@0.19.0': + resolution: {integrity: sha512-xrZjF6XOEwyLOiDIfrwDdRCS6ghxcJeirNC1KivQhVcUwlRonaU79S3/Uiyx3lBylPnT8qKJ3/aMT+H52nGTJQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-core/binding-linux-arm-gnu@0.19.0': + resolution: {integrity: sha512-0OyIH53C0I1CbADHn9FSdCfX5qbpeLwJhmOVhonFJNYheybOnkSJ50qGMfTJIb8y5zrwqvLs7FVBvz6Fk8maZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-arm-musl@0.19.0': + resolution: {integrity: sha512-z/iPfkCOCSXE34VXNt0uP1ORSW+4mEa9b5CiJcWZeqZH08g49bFU/jRM2T04f+Wpw5mJ6HIzNBiojxeAbrHjeg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-core/binding-linux-arm64-gnu@0.19.0': + resolution: {integrity: sha512-jLNTi+gzaRwBJ+gwhCJ+rDb5dmx/8hEBx93UWd7hjMSqPTL1D1wvVSvXVAldV5kALvju7b0/m51EHEhs4+I1fA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-arm64-musl@0.19.0': + resolution: {integrity: sha512-YwyPIdW/rN2Fmv0MTFWi47FaB3F9g+2b+sJISyAw83Yd60RWH4T3Nx6XkAHHVN+uKJNBAd6So7/BVihSiFYvuQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-core/binding-linux-x64-gnu@0.19.0': + resolution: {integrity: sha512-/vskaAXVX3ESR43FiTdfiYS7z1b9uoQJwVQ/0RgUHgN2sWoAyCZs+aX4CgU7eKGIHTVfp9VFwv+c+2XchYiEtQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-x64-musl@0.19.0': + resolution: {integrity: sha512-FuXL5igeSKib9Rye02TdH+D6SQezTyGxIATMu1Qsn/QywrzwpGl35OLpS2C1x5vpGLeVE2XgiLd5mVPFHmIglQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-core/binding-win32-arm64@0.19.0': + resolution: {integrity: sha512-YuJAe7zEKT1Xa7LuHlhGJpPnYQeVtvo0rZvNjJRqkH0hQGOw4C8QeP30Tg5VKGzZfo2x8CjhjEhKt4TmLwfmdg==} + cpu: [arm64] + os: [win32] + + '@yuku-core/binding-win32-x64@0.19.0': + resolution: {integrity: sha512-o8iY/mXwlRkedp0LNTr4+TEfXTPQwoNZmv6k+1WIAd1ZXEn8xJJTBCtB0osaVvdZo/x/cNmV16UrWrrGpM5Mvw==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.19.0': + resolution: {integrity: sha512-tFkNbsP4WXX9UMUC5syJGdK9dPMLK0PPaIuXCcCYH75sZ8MrebfBZ2XvCILRTw8AgLivSSCfDa4R67iYBDa4Kg==} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.4.0: + resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==} + hasBin: true + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + 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@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astro-expressive-code@0.44.2: + resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 + + astro@7.3.2: + resolution: {integrity: sha512-ysTcdpGP61XZpHoMRYC/CK19DQ94qkBvzXMtXEo0gEqPNkmOU/Tv++CtWmzaI4nF2Ck8RXFdiWvdlTWa2oOr9w==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.1: + resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-module-lexer@3.0.4: + resolution: {integrity: sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + expressive-code@0.44.2: + resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + find-proc@0.1.0: + resolution: {integrity: sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==} + engines: {node: ^20.19.0 || >=22.12.0} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + 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-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + 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.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + + i18next@26.5.2: + resolution: {integrity: sha512-6fIXt//2MZNok3tULyjnypxe3vbsy3pV1bVg8p2x3yKrzZvp7z3T+wXx/88LQsATRdhyJeAUAvUxEtxsHdJ29w==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + 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@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-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + 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'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mdast-util-directive@3.1.1: + resolution: {integrity: sha512-Gqpa3MHorXYEZ0VfbroyvBlVKUfz371V6mIERqOYMcb0W1D3LwmFq1ZW4O2TDrbn+4ZmGLwMWeFRJgYZ+bc5fQ==} + + mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} + + 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.2.0: + resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} + + 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.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + miniflare@5.20261012.1-beta: + resolution: {integrity: sha512-8Canhy6g9+vAhqMc0o80YphqYIwpiA3aAp8aX+uDXU7J/qbUeoZzcII9c2V1P4uT/eHD4SB93qet1o1bRY3BuQ==} + engines: {node: '>=22.0.0'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + oxfmt@0.73.0: + resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@7.0.2004: + resolution: {integrity: sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==} + hasBin: true + + oxlint@1.88.0: + resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + p-queue@9.3.5: + resolution: {integrity: sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + 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.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + prettier-plugin-astro@1.1.1: + resolution: {integrity: sha512-ykymNFOinCFXY4z4yF0978GgAvg6GRtrzCYg3+R1Nv/dndACwuEsSrUpHkJohER9FiwYMPKuYT38FbjCLmwu9A==} + engines: {node: '>=22.12.0'} + peerDependencies: + prettier: ^3.5.3 + + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + pyodide@314.0.7: + resolution: {integrity: sha512-0YvXxEhfEdpLfb/XkM2BFAeMROq0iMUX2bzzH9pOttyMcWkwq+HbE5uyuGD82LN7y2q+SNvi/6V5JEsOlD2R1A==} + engines: {node: '>=18.0.0'} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.44.2: + resolution: {integrity: sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==} + + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + s.color@0.0.15: + resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==} + + sass-formatter@0.8.0: + resolution: {integrity: sha512-Nf4eMGE2O5vnJG7KPCRyksWS9gVKSYoUWtPeMXRRxwZSPVQhSGtL602xVYS65LvMkHmkvKa7xpauF0+/5pPwrg==} + hasBin: true + + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shiki@4.5.0: + resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==} + engines: {node: '>=20'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + style-mod@4.1.4: + resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + + suf-log@2.5.3: + resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyclip@0.1.15: + resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + 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==} + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + undici@8.12.2: + resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==} + engines: {node: '>=22.19.0'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.6: + resolution: {integrity: sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw==} + 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 || ^11.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 + + url-extras@0.1.0: + resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} + engines: {node: '>=20'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-plus@1.3.0: + resolution: {integrity: sha512-wZGsDeYfPcrCegqgY68pJJHGTpEZdsZvIMH2/mWXkGkBD72UY3M7z24Sv3ZeOL/FrqbbtyqBOfkKO6BvoQ8wMw==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + playwright: '*' + peerDependenciesMeta: + '@vitest/browser-playwright': + optional: true + '@vitest/browser-webdriverio': + optional: true + playwright: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + typescript: '*' + peerDependenciesMeta: + '@volar/language-service': + optional: true + typescript: + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + typescript: '*' + peerDependenciesMeta: + '@volar/language-service': + optional: true + typescript: + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-jsonrpc@9.0.3: + resolution: {integrity: sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-protocol@3.18.4: + resolution: {integrity: sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg==} + + vscode-languageserver-textdocument@1.0.15: + resolution: {integrity: sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.4: + resolution: {integrity: sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + workerd@1.20261012.1: + resolution: {integrity: sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.151.1: + resolution: {integrity: sha512-OAsa9UAHZshM6mq2PXcVoyxXgWThAanZMBc/IirtqEWd/BauRtmUKzAniOu/pU0pd3pxKuMZ2vsbF4eo6RiNDA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261006.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + 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 + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + 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'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@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==} + + yuku-core@0.19.0: + resolution: {integrity: sha512-kYK9e1XdfirNhkipzW0wmMcSWcVF/CWnuugodfVYRCUwvoBefweL9HzB96OiKEhsx78V5WbUxbJqlizMX869Qg==} + engines: {node: '>=14.18.0'} + + yuku-parser@0.19.0: + resolution: {integrity: sha512-XrBh9ZiQK5mJVRxZBAvD6LwRyAYrK3MFtH3+onyuxFL5hFD44EC3QOmIC1FjTj6H2MMwq46/NAI+NZxLZPZgxg==} + engines: {node: '>=14.18.0'} + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/astro2tsx@0.1.3(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/runtime' + + '@astrojs/check@0.9.10(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10))(prettier@3.9.10)(typescript@6.0.3)': + dependencies: + '@astrojs/language-server': 2.17.2(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10))(prettier@3.9.10)(typescript@6.0.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 6.0.3 + yargs: 18.2.0 + transitivePeerDependencies: + - '@emnapi/runtime' + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-android-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.4.1': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.4.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.4.1': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.4.1': + optional: true + + '@astrojs/compiler-binding@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-arm64': 0.4.1 + '@astrojs/compiler-binding-darwin-x64': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-arm64-musl': 0.4.1 + '@astrojs/compiler-binding-linux-x64-gnu': 0.4.1 + '@astrojs/compiler-binding-linux-x64-musl': 0.4.1 + '@astrojs/compiler-binding-wasm32-wasi': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.1 + '@astrojs/compiler-binding-win32-x64-msvc': 0.4.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@astrojs/compiler-binding': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/internal-helpers@0.11.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + js-yaml: 4.3.2 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.5.0 + smol-toml: 1.9.1 + unified: 11.0.5 + + '@astrojs/internal-helpers@0.13.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.5.0 + smol-toml: 1.9.1 + unified: 11.0.5 + yaml: 2.9.1 + + '@astrojs/language-server@2.17.2(@emnapi/runtime@1.11.3)(prettier-plugin-astro@1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10))(prettier@3.9.10)(typescript@6.0.3)': + dependencies: + '@astrojs/astro2tsx': 0.1.3(@emnapi/runtime@1.11.3) + '@astrojs/yaml2ts': 0.2.4 + '@volar/kit': 2.4.28(typescript@6.0.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28(typescript@6.0.3) + '@volar/language-service': 2.4.28(typescript@6.0.3) + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-html: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.10) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.2.0 + optionalDependencies: + prettier: 3.9.10 + prettier-plugin-astro: 1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10) + transitivePeerDependencies: + - '@emnapi/runtime' + - typescript + + '@astrojs/markdown-satteri@0.4.1': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/markdown-satteri@0.4.4': + dependencies: + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/prism': 4.1.0 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/mdx@8.1.0(@astrojs/markdown-satteri@0.4.4)(astro@7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1))': + dependencies: + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/markdown-satteri': 0.4.4 + astro: 7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1) + es-module-lexer: 3.0.4 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/prism@4.1.0': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.4': + dependencies: + sitemap: 9.0.1 + zod: 4.6.7 + + '@astrojs/starlight@0.42.1(astro@7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@astrojs/markdown-satteri': 0.4.4 + '@astrojs/mdx': 8.1.0(@astrojs/markdown-satteri@0.4.4)(astro@7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1)) + '@astrojs/sitemap': 3.7.4 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.5 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1) + astro-expressive-code: 0.44.2(astro@7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1)) + bcp-47: 2.1.1 + hast-util-format: 1.1.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.5.2(typescript@6.0.3) + js-yaml: 4.3.2 + klona: 2.0.6 + magic-string: 1.4.3 + mdast-util-directive: 3.1.1(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + remark-directive: 4.0.0(supports-color@10.2.2) + satteri: 0.10.5 + ultrahtml: 1.7.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@astrojs/telemetry@3.3.3': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.9.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.10': {} + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@blazediff/core@1.10.0': {} + + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261012.1 + + '@cloudflare/workerd-darwin-64@1.20261012.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261012.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261012.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261012.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261012.1': + optional: true + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.13.1 + '@lezer/json': 1.0.3 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@lezer/common': 1.5.3 + '@lezer/python': 1.1.19 + + '@codemirror/language@6.13.1': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/streamparser': 6.0.0 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + style-mod: 4.1.4 + + '@codemirror/state@6.7.6': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/streamparser@6.0.0': {} + + '@codemirror/view@6.43.14': + dependencies: + '@codemirror/state': 6.7.6 + crelt: 1.0.7 + style-mod: 4.1.4 + w3c-keyname: 2.2.8 + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@ctrl/tinycolor@4.2.1': {} + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@expressive-code/core@0.44.2': + dependencies: + '@ctrl/tinycolor': 4.2.1 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.30 + postcss-nested: 6.2.0(postcss@8.5.30) + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@expressive-code/plugin-shiki@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + shiki: 4.5.0 + + '@expressive-code/plugin-text-markers@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@fontsource-variable/jetbrains-mono@5.3.0': {} + + '@fontsource-variable/mona-sans@5.3.0': {} + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@lezer/common@1.5.3': {} + + '@lezer/highlight@1.2.5': + dependencies: + '@lezer/common': 1.5.3 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + + '@lezer/lr@1.4.11': + dependencies: + '@lezer/common': 1.5.3 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + + '@marijn/find-cluster-break@1.0.4': {} + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/runtime@0.154.0': {} + + '@oxc-project/types@0.154.0': {} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + optional: true + + '@oxfmt/binding-android-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.73.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.73.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.73.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2004': + optional: true + + '@oxlint/binding-android-arm-eabi@1.88.0': + optional: true + + '@oxlint/binding-android-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-x64@1.88.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.88.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.88.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.88.0': + optional: true + + '@oxlint/plugins@1.88.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 + + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + + '@polka/url@1.0.0-next.29': {} + + '@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': {} + + '@prettier/parse-srcset@3.1.0': {} + + '@shikijs/core@4.5.0': + dependencies: + '@shikijs/primitive': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/primitive@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/types@4.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.10 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/emscripten@1.41.6': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 26.6.5 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.4.0': {} + + '@vitest/browser-preview@5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(vitest@5.0.5)(yaml@2.9.1)': + dependencies: + '@testing-library/dom': 10.4.2 + '@testing-library/user-event': 14.6.8(@testing-library/dom@10.4.2) + '@vitest/browser': 5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(vitest@5.0.5)(yaml@2.9.1) + vitest: 5.0.5(@types/node@26.6.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@types/node' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@volar/typescript' + - '@vue/language-core' + - bufferutil + - esbuild + - jiti + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - yaml + + '@vitest/browser@5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(vitest@5.0.5)(yaml@2.9.1)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + '@vitest/ui': 5.0.5(vitest@5.0.5) + '@vitest/utils': 5.0.5 + magic-string: 1.4.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@26.6.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + ws: 8.22.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@types/node' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@volar/typescript' + - '@vue/language-core' + - bufferutil + - esbuild + - jiti + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - yaml + + '@vitest/mocker@5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)' + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@types/node' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@volar/typescript' + - '@vue/language-core' + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - vue-tsc + - yaml + + '@vitest/pretty-format@5.0.5': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/snapshot@5.0.5': + dependencies: + '@vitest/pretty-format': 5.0.5 + '@vitest/utils': 5.0.5 + magic-string: 1.4.3 + pathe: 2.0.3 + + '@vitest/spy@5.0.5': {} + + '@vitest/ui@5.0.5(vitest@5.0.5)': + dependencies: + '@vitest/utils': 5.0.5 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@26.6.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + + '@vitest/utils@5.0.5': + dependencies: + '@vitest/pretty-format': 5.0.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + + '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)': + dependencies: + '@oxc-project/runtime': 0.154.0 + '@oxc-project/types': 0.154.0 + lightningcss: 1.33.0 + postcss: 8.5.30 + yuku-parser: 0.19.0 + optionalDependencies: + '@types/node': 26.6.5 + '@voidzero-dev/vite-plus-darwin-arm64': 1.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.3.0 + '@volar/typescript': 2.4.28(typescript@6.0.3) + esbuild: 0.28.2 + fsevents: 2.3.3 + typescript: 6.0.3 + yaml: 2.9.1 + + '@voidzero-dev/vite-plus-darwin-arm64@1.3.0': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@1.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@1.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@1.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@1.3.0': + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.3.0': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@1.3.0': + optional: true + + '@volar/kit@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + '@volar/typescript': 2.4.28(typescript@6.0.3) + typesafe-path: 0.2.2 + typescript: 6.0.3 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28(typescript@6.0.3) + '@volar/typescript': 2.4.28(typescript@6.0.3) + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.18.4 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@volar/language-service@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.18.4 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 6.0.3 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + + '@vscode/l10n@0.0.18': {} + + '@yuku-core/binding-android-arm64@0.19.0': + optional: true + + '@yuku-core/binding-darwin-arm64@0.19.0': + optional: true + + '@yuku-core/binding-darwin-x64@0.19.0': + optional: true + + '@yuku-core/binding-freebsd-x64@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm-musl@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm64-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm64-musl@0.19.0': + optional: true + + '@yuku-core/binding-linux-x64-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-x64-musl@0.19.0': + optional: true + + '@yuku-core/binding-win32-arm64@0.19.0': + optional: true + + '@yuku-core/binding-win32-x64@0.19.0': + optional: true + + '@yuku-toolchain/types@0.19.0': {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.4.0: + dependencies: + process-ancestry: 0.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.4.0: {} + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + 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.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + astro-expressive-code@0.44.2(astro@7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1)): + dependencies: + astro: 7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1) + rehype-expressive-code: 0.44.2 + url-extras: 0.1.0 + + astro@7.3.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(typescript@6.0.3)(yaml@2.9.1): + dependencies: + '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.1 + '@astrojs/telemetry': 3.3.3 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.4.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 5.9.4 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 2.3.2 + esbuild: 0.28.2 + find-proc: 0.1.0 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.3.0 + js-yaml: 4.3.2 + jsonc-parser: 3.3.1 + magic-string: 1.4.3 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 2.2.1 + p-limit: 7.3.3 + p-queue: 9.3.5 + package-manager-detector: 1.9.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + semver: 7.8.5 + shiki: 4.5.0 + smol-toml: 1.9.1 + svgo: 4.1.0 + tinyclip: 0.1.15 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + ultrahtml: 1.7.0 + unifont: 0.7.5 + unstorage: 1.17.6 + vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)' + vitefu: 1.1.3(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.7 + optionalDependencies: + sharp: 0.35.5(@types/node@26.6.5) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@emnapi/core' + - '@emnapi/runtime' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@typescript/native-preview' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - '@volar/typescript' + - '@vue/language-core' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - uploadthing + - vue-tsc + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.1: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + blake3-wasm@2.1.5: {} + + boolbase@1.0.0: {} + + ccount@2.0.1: {} + + chai@6.3.0: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + crelt@1.0.7: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css-what@7.0.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + 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.9.4: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + + direction@2.0.1: {} + + dom-accessibility-api@0.5.16: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + + entities@4.5.0: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.2: {} + + es-module-lexer@3.0.4: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + expressive-code@0.44.2: + dependencies: + '@expressive-code/core': 0.44.2 + '@expressive-code/plugin-frames': 0.44.2 + '@expressive-code/plugin-shiki': 0.44.2 + '@expressive-code/plugin-text-markers': 0.44.2 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + + find-proc@0.1.0: {} + + flatted@3.4.4: {} + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.7.0: {} + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.3.0: {} + + i18next@26.5.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + 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@4.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + klona@2.0.6: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + longest-streak@3.1.0: {} + + lru-cache@11.5.3: {} + + lz-string@1.5.0: {} + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + + mdast-util-directive@3.1.1(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0(supports-color@10.2.2) + mdast-util-to-markdown: 2.2.0 + micromark-util-character: 2.1.1 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.1.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3(supports-color@10.2.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.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.2.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + parse-entities: 4.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3(supports-color@10.2.2): + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@10.2.2) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + miniflare@5.20261012.1-beta(@types/node@26.6.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@26.6.5) + undici: 7.29.1 + workerd: 1.20261012.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.20: {} + + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.2.1: {} + + obug@3.0.0: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + oxfmt@0.73.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(vite-plus@1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1)): + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@oxfmt/binding-android-arm-eabi': 0.73.0 + '@oxfmt/binding-android-arm64': 0.73.0 + '@oxfmt/binding-darwin-arm64': 0.73.0 + '@oxfmt/binding-darwin-x64': 0.73.0 + '@oxfmt/binding-freebsd-x64': 0.73.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.73.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.73.0 + '@oxfmt/binding-linux-arm64-gnu': 0.73.0 + '@oxfmt/binding-linux-arm64-musl': 0.73.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-musl': 0.73.0 + '@oxfmt/binding-linux-s390x-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-musl': 0.73.0 + '@oxfmt/binding-openharmony-arm64': 0.73.0 + '@oxfmt/binding-win32-arm64-msvc': 0.73.0 + '@oxfmt/binding-win32-ia32-msvc': 0.73.0 + '@oxfmt/binding-win32-x64-msvc': 0.73.0 + vite-plus: 1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1) + + oxlint-tsgolint@7.0.2004: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2004 + '@oxlint-tsgolint/darwin-x64': 7.0.2004 + '@oxlint-tsgolint/linux-arm64': 7.0.2004 + '@oxlint-tsgolint/linux-x64': 7.0.2004 + '@oxlint-tsgolint/win32-arm64': 7.0.2004 + '@oxlint-tsgolint/win32-x64': 7.0.2004 + + oxlint@1.88.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.88.0 + '@oxlint/binding-android-arm64': 1.88.0 + '@oxlint/binding-darwin-arm64': 1.88.0 + '@oxlint/binding-darwin-x64': 1.88.0 + '@oxlint/binding-freebsd-x64': 1.88.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.88.0 + '@oxlint/binding-linux-arm-musleabihf': 1.88.0 + '@oxlint/binding-linux-arm64-gnu': 1.88.0 + '@oxlint/binding-linux-arm64-musl': 1.88.0 + '@oxlint/binding-linux-ppc64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-musl': 1.88.0 + '@oxlint/binding-linux-s390x-gnu': 1.88.0 + '@oxlint/binding-linux-x64-gnu': 1.88.0 + '@oxlint/binding-linux-x64-musl': 1.88.0 + '@oxlint/binding-openharmony-arm64': 1.88.0 + '@oxlint/binding-win32-arm64-msvc': 1.88.0 + '@oxlint/binding-win32-ia32-msvc': 1.88.0 + '@oxlint/binding-win32-x64-msvc': 1.88.0 + oxlint-tsgolint: 7.0.2004 + vite-plus: 1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1) + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.5: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + + package-manager-detector@1.9.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 + + path-browserify@1.0.1: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + + pngjs@7.0.0: {} + + postcss-nested@6.2.0(postcss@8.5.30): + dependencies: + postcss: 8.5.30 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prettier-plugin-astro@1.1.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(prettier@3.9.10): + dependencies: + '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@prettier/parse-srcset': 3.1.0 + prettier: 3.9.10 + sass-formatter: 0.8.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + prettier@3.9.10: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + property-information@7.2.0: {} + + pyodide@314.0.7: + dependencies: + '@types/emscripten': 1.41.6 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + radix3@1.1.2: {} + + react-is@17.0.2: {} + + readdirp@4.1.2: {} + + readdirp@5.1.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.44.2: + dependencies: + expressive-code: 0.44.2 + + remark-directive@4.0.0(supports-color@10.2.2): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.1(supports-color@10.2.2) + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + s.color@0.0.15: {} + + sass-formatter@0.8.0: + dependencies: + suf-log: 2.5.3 + + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + + semver@7.8.5: {} + + sharp@0.35.5(@types/node@26.6.5): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 26.6.5 + + shiki@4.5.0: + dependencies: + '@shikijs/core': 4.5.0 + '@shikijs/engine-javascript': 4.5.0 + '@shikijs/engine-oniguruma': 4.5.0 + '@shikijs/langs': 4.5.0 + '@shikijs/themes': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.19.2 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.1 + + smol-toml@1.9.1: {} + + source-map-js@1.2.2: {} + + space-separated-tokens@2.0.2: {} + + std-env@4.3.0: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + + style-mod@4.1.4: {} + + suf-log@2.5.3: + dependencies: + s.color: 0.0.15 + + supports-color@10.2.2: {} + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + tiny-inflate@1.0.3: {} + + tinybench@6.2.2: {} + + tinyclip@0.1.15: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.2.0: {} + + tinyrainbow@3.2.0: {} + + totalist@3.0.1: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.24.6: {} + + undici-types@8.9.0: {} + + undici@7.29.1: {} + + undici@8.12.2: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.5: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.12 + undici: 8.12.2 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.6: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + url-extras@0.1.0: {} + + util-deprecate@1.0.2: {} + + 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-plus@1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1): + dependencies: + '@oxc-project/types': 0.154.0 + '@oxlint/plugins': 1.88.0 + '@vitest/browser': 5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(vitest@5.0.5)(yaml@2.9.1) + '@vitest/browser-preview': 5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(vitest@5.0.5)(yaml@2.9.1) + '@vitest/mocker': 5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + '@vitest/pretty-format': 5.0.5 + '@vitest/snapshot': 5.0.5 + '@vitest/spy': 5.0.5 + '@vitest/utils': 5.0.5 + oxfmt: 0.73.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(vite-plus@1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1)) + oxlint: 1.88.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.3.0(@astrojs/compiler-rs@0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(@types/node@26.6.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(playwright@1.64.1)(typescript@6.0.3)(yaml@2.9.1)) + oxlint-tsgolint: 7.0.2004 + vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)' + vitest: 5.0.5(@types/node@26.6.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 1.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.3.0 + playwright: 1.64.1 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@astrojs/compiler-rs' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - '@volar/typescript' + - '@vue/language-core' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vue-tsc + - yaml + + vitefu@1.1.3(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1): + dependencies: + vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)' + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@types/node' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@volar/typescript' + - '@vue/language-core' + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - vue-tsc + - yaml + + vitest@5.0.5(@types/node@26.6.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(yaml@2.9.1)' + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.5 + '@vitest/browser-preview': 5.0.5(@types/node@26.6.5)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(typescript@6.0.3)(vitest@5.0.5)(yaml@2.9.1) + '@vitest/ui': 5.0.5(vitest@5.0.5) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@volar/typescript' + - '@vue/language-core' + - esbuild + - jiti + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - vue-tsc + - yaml + + volar-service-css@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + volar-service-html@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.10): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + prettier: 3.9.10 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + typescript: 6.0.3 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3): + dependencies: + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.15 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + typescript: 6.0.3 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + dependencies: + vscode-uri: 3.2.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.28(typescript@6.0.3) + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.2.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-jsonrpc@9.0.3: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-protocol@3.18.4: + dependencies: + vscode-jsonrpc: 9.0.3 + vscode-languageserver-types: 3.18.4 + + vscode-languageserver-textdocument@1.0.15: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.4: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.2.0: {} + + w3c-keyname@2.2.8: {} + + why-is-node-running@3.2.1: {} + + workerd@1.20261012.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261012.1 + '@cloudflare/workerd-darwin-arm64': 1.20261012.1 + '@cloudflare/workerd-linux-64': 1.20261012.1 + '@cloudflare/workerd-linux-arm64': 1.20261012.1 + '@cloudflare/workerd-windows-64': 1.20261012.1 + + wrangler@4.151.1(@types/node@26.6.5): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261012.1-beta(@types/node@26.6.5) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261012.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + ws@8.21.0: {} + + ws@8.22.0: {} + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.10 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.1: {} + + yargs-parser@22.0.0: {} + + yargs@18.2.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.3.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@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.24 + cookie: 1.1.1 + youch-core: 0.3.3 + + yuku-core@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + optionalDependencies: + '@yuku-core/binding-android-arm64': 0.19.0 + '@yuku-core/binding-darwin-arm64': 0.19.0 + '@yuku-core/binding-darwin-x64': 0.19.0 + '@yuku-core/binding-freebsd-x64': 0.19.0 + '@yuku-core/binding-linux-arm-gnu': 0.19.0 + '@yuku-core/binding-linux-arm-musl': 0.19.0 + '@yuku-core/binding-linux-arm64-gnu': 0.19.0 + '@yuku-core/binding-linux-arm64-musl': 0.19.0 + '@yuku-core/binding-linux-x64-gnu': 0.19.0 + '@yuku-core/binding-linux-x64-musl': 0.19.0 + '@yuku-core/binding-win32-arm64': 0.19.0 + '@yuku-core/binding-win32-x64': 0.19.0 + + yuku-parser@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + yuku-core: 0.19.0 + + zod@4.6.7: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..03c7697 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,17 @@ +packages: + - landing + - docs + - playground + +allowBuilds: + esbuild: true + sharp: true + workerd: true + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@1.3.0 + +# The alias reports the Vite+ version; it bundles compatible Vite 8.3.4. +peerDependencyRules: + allowedVersions: + vite: '1.3.0' diff --git a/pyproject.toml b/pyproject.toml index 4655c57..881253c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mlbundle" -version = "0.1.0a3" +version = "0.2.0" description = "Pickle-free, verifiable model bundles with a manifest, schema and structure." readme = "README.md" license = "MIT" @@ -20,7 +20,6 @@ keywords = [ "skops", ] classifiers = [ - "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Natural Language :: English", @@ -46,12 +45,12 @@ xgboost = ["xgboost>=3.4.1", "scikit-learn>=1.2"] # Audited CBM schema and Python 3.14 wheels on Windows/Linux. catboost = ["catboost>=1.2.10"] onnx = ["onnx>=1.23.0", "onnxruntime>=1.30.0"] -# The reader validates the 0.2 inferred vocabulary; 0.2.1 requires Python <3.15. -schema = ["mlschema>=0.2.1,<0.3; python_version == '3.14'"] +# The reader validates the 0.3 vocabulary; schema inference requires Python 3.14. +schema = ["mlschema>=0.3.0,<0.4; python_version == '3.14'"] all = [ "scikit-learn>=1.6", "skops>=0.16.0", "xgboost>=3.4.1", "catboost>=1.2.10", "onnx>=1.23.0", "onnxruntime>=1.30.0", - "mlschema>=0.2.1,<0.3; python_version == '3.14'", + "mlschema>=0.3.0,<0.4; python_version == '3.14'", ] [project.urls] diff --git a/scripts/package_smoke.py b/scripts/package_smoke.py index 5578ac5..01c733c 100644 --- a/scripts/package_smoke.py +++ b/scripts/package_smoke.py @@ -52,7 +52,7 @@ def _base(path: Path) -> None: import mlbundle - assert mlbundle.__version__ == "0.1.0a3" + assert mlbundle.__version__ == "0.2.0" view = mlbundle.inspect(path) assert view.manifest.model.library == "sklearn" assert "cannot attest" in _console("--help") diff --git a/scripts/publish_check.py b/scripts/publish_check.py index e778ab2..68f5536 100644 --- a/scripts/publish_check.py +++ b/scripts/publish_check.py @@ -1,6 +1,8 @@ -"""Read-only preflight for the owner's manual PyPI workflow.""" +"""Check a main-branch version change before the automatic PyPI workflow.""" +import argparse import os +import subprocess import sys import tomllib from pathlib import Path @@ -8,10 +10,13 @@ from urllib.request import urlopen -def check(ref: str, project: dict) -> None: +def check(ref: str, project: dict, previous_version: str | None = None) -> bool: if ref != "refs/heads/main": raise SystemExit("Publishing requires refs/heads/main; select main.") version = project["version"] + if version == previous_version: + sys.stdout.write(f"Version remains {version}; skipping PyPI publication.\n") + return False url = f"https://pypi.org/pypi/mlbundle/{version}/json" try: with urlopen(url, timeout=30): @@ -25,10 +30,36 @@ def check(ref: str, project: dict) -> None: else: raise SystemExit(f"mlbundle {version} already exists on PyPI; refusing upload.") sys.stdout.write(f"mlbundle {version} is not on PyPI (HTTP 404); main ref OK.\n") + return True + + +def version_at(ref: str) -> str | None: + if not ref or set(ref) == {"0"}: + return None + result = subprocess.run( + ["git", "show", f"{ref}:pyproject.toml"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise SystemExit("Could not read the previous pyproject.toml version.") + return tomllib.loads(result.stdout)["project"]["version"] + + +def write_output(path: str, publish: bool) -> None: + with Path(path).open("a", encoding="utf-8") as output: + output.write(f"publish={str(publish).lower()}\n") if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--previous-ref") + args = parser.parse_args() project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))[ "project" ] - check(os.environ["GITHUB_REF"], project) + previous_version = version_at(args.previous_ref) if args.previous_ref else None + publish = check(os.environ["GITHUB_REF"], project, previous_version) + if args.previous_ref and os.environ.get("GITHUB_OUTPUT"): + write_output(os.environ["GITHUB_OUTPUT"], publish) diff --git a/src/mlbundle/_schema_inference.py b/src/mlbundle/_schema_inference.py deleted file mode 100644 index dd27903..0000000 --- a/src/mlbundle/_schema_inference.py +++ /dev/null @@ -1,28 +0,0 @@ -"""The temporary adaptation between local mlschema inference and MLForm.""" - - -def mlform_fields(fields: list[dict]) -> list[dict]: - # Adapt mlschema 0.2.1 to MLForm 0.1.25 until mlschema emits series columns. - return [ - { - **{ - key: value - for key, value in field.items() - if key not in ("field1", "field2") - }, - "columns": [ - { - **{ - key: value - for key, value in field[axis].items() - if key != "mappedTo" - }, - "id": axis, - } - for axis in ("field1", "field2") - ], - } - if field.get("kind") == "series" - else field - for field in fields - ] diff --git a/src/mlbundle/schema.py b/src/mlbundle/schema.py index fc3f616..6a5b9de 100644 --- a/src/mlbundle/schema.py +++ b/src/mlbundle/schema.py @@ -76,21 +76,6 @@ def reports_for(claims: ModelClaims) -> list[dict[str, JsonValue]]: return reports -def positional_fields(fields: list[dict], names: list[str]) -> None: - """Rewrite inferred name mappings, retaining mlschema's labels and grouping.""" - positions = {name: index for index, name in enumerate(names)} - - def rewrite(field: dict) -> None: - if "mappedTo" in field: - field["mappedTo"] = positions[field["mappedTo"]] - for option in field.get("options", []): - if isinstance(option, dict): - rewrite(option) - - for field in fields: - rewrite(field) - - def _mapping_index( mapping: str | int, positions: dict[str, int], origin: FeatureOrigin ) -> int: diff --git a/src/mlbundle/training.py b/src/mlbundle/training.py index 4909c9d..b927d87 100644 --- a/src/mlbundle/training.py +++ b/src/mlbundle/training.py @@ -9,7 +9,6 @@ from pydantic import ValidationError -from ._schema_inference import mlform_fields from .canonical import canonical_dumps, canonical_value from .claims import ModelClaims from .contract import FeatureOrigin, MemberName @@ -23,7 +22,7 @@ from .limits import ArchiveLimits from .manifest import FeatureInfo from .samples import sample_bytes -from .schema import positional_fields, reports_for, validate_schema, validate_separator +from .schema import reports_for, validate_schema, validate_separator if TYPE_CHECKING: from pandas import DataFrame @@ -125,9 +124,9 @@ def prepare_training( dtypes = [None] * claims.n_features cap = limits.member_bytes(MemberName.SCHEMA) try: - fields = mlform_fields(mlschema.infer_schema(frame, onehot_separator=separator)) + fields = mlschema.infer_schema(frame, onehot_separator=separator) if origin in (FeatureOrigin.DATAFRAME, FeatureOrigin.DECLARED): - positional_fields(fields, names) + fields = mlschema.with_positional_mappings(fields, names) schema = canonical_value( { "schema_version": "2", diff --git a/tests/test_publish.py b/tests/test_publish.py index b8f4bae..d195811 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -1,4 +1,4 @@ -"""Manual publish preflight rejects other refs, existing versions and outages.""" +"""Publish preflight gates version changes, duplicate releases and outages.""" import runpy from contextlib import nullcontext @@ -36,7 +36,7 @@ def response(url, timeout): monkeypatch.setitem(preflight.__globals__, "urlopen", response) if code == 404: - preflight("refs/heads/main", {"version": "0.1.0a3"}) + assert preflight("refs/heads/main", {"version": "0.1.0a3"}) is True assert "HTTP 404" in capsys.readouterr().out else: message = "already exists" if code == 200 else f"HTTP {code}" @@ -44,6 +44,26 @@ def response(url, timeout): preflight("refs/heads/main", {"version": "0.1.0a3"}) +def test_unchanged_version_skips_network(preflight, monkeypatch, capsys): + def unexpected(*args, **kwargs): + raise AssertionError("An unchanged version must not query PyPI") + + monkeypatch.setitem(preflight.__globals__, "urlopen", unexpected) + assert ( + preflight("refs/heads/main", {"version": "0.2.0"}, previous_version="0.2.0") + is False + ) + assert "skipping" in capsys.readouterr().out + + +def test_output_records_publish_decision(preflight, tmp_path): + write_output = preflight.__globals__["write_output"] + output = tmp_path / "github-output" + write_output(str(output), True) + write_output(str(output), False) + assert output.read_text(encoding="utf-8") == "publish=true\npublish=false\n" + + @pytest.mark.parametrize("error", [URLError("offline"), TimeoutError("timeout")]) def test_network_failure_is_closed(preflight, monkeypatch, error): def response(*args, **kwargs): diff --git a/tests/test_readme.py b/tests/test_readme.py index c7cf74b..1f38482 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -1,4 +1,4 @@ -"""Execute the actual fenced README examples, including the console commands.""" +"""Execute the README quick start and its mlbundle CLI commands.""" import re import shlex @@ -8,8 +8,6 @@ import pytest -from onnx_helpers import softmax - README = Path(__file__).resolve().parents[1] / "README.md" BLOCKS = re.findall( r"```(\w+)\n(.*?)\n```", README.read_text(encoding="utf-8"), re.DOTALL @@ -17,32 +15,20 @@ PYTHON = [code for language, code in BLOCKS if language == "python"] -@pytest.mark.parametrize( - "code", - PYTHON, - ids=[ - "sklearn", - "records", - "xgboost", - "catboost", - "composite", - "onnx", - "schema-samples", - ], -) +@pytest.mark.parametrize("code", PYTHON) def test_python_example(code, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - if 'record = {"0"' in code: - exec(compile(PYTHON[0], str(README), "exec"), {}) - if 'Path("trained.onnx")' in code: - # The user's prerequisite file: a self-contained, two-column classifier. - Path("trained.onnx").write_bytes(softmax().SerializeToString()) - exec(compile(code, str(README), "exec"), {}) + scope = {} + exec(compile(code, str(README), "exec"), scope) + assert scope["metadata"].samples is None + assert scope["bundle"].feature_names == ("temperature", "humidity") + assert scope["result"].predicted_label == "cold" + assert scope["result"].probabilities == (1.0, 0.0) def test_console_examples(tmp_path, monkeypatch): - assert len(PYTHON) == 7 - assert all(language in {"python", "shell"} for language, _ in BLOCKS) + assert PYTHON + assert all(language in {"python", "shell", "bash"} for language, _ in BLOCKS) monkeypatch.chdir(tmp_path) exec(compile(PYTHON[0], str(README), "exec"), {}) executable = Path(sys.executable).with_name( diff --git a/tests/test_series_schema.py b/tests/test_series_schema.py index 3145c77..acdbcc4 100644 --- a/tests/test_series_schema.py +++ b/tests/test_series_schema.py @@ -1,4 +1,4 @@ -"""The sole mlschema shape adaptation and its closed reader contract.""" +"""Native mlschema series output and its closed reader contract.""" import copy from typing import get_args @@ -15,7 +15,7 @@ from mlbundle.writing import write_container -def test_field_vocabulary_matches_mlschema_except_series(): +def test_field_vocabulary_matches_mlschema(): from mlschema.strategies.app import builtin_kinds from mlschema.strategies.domain.onehot_category_field import OneHotCategoryOption @@ -27,12 +27,7 @@ def test_field_vocabulary_matches_mlschema_except_series(): theirs = {item.name: set(item.model.model_fields) for item in builtin_kinds()} assert ours.keys() == theirs.keys() for kind, keys in ours.items(): - if kind == "series": - # The MLForm columns adaptation is the only inferred shape difference. - assert keys - theirs[kind] == {"columns"} - assert {"field1", "field2"} <= theirs[kind] - keys - else: - assert keys <= theirs[kind], kind + assert keys <= theirs[kind], kind assert set(OneHotOption.model_fields) <= set(OneHotCategoryOption.model_fields) @@ -45,25 +40,7 @@ def series_bundle(tmp_path): ) original = infer_schema(frame)[0] field = view.schema["fields"][0] - assert field == { - **{ - key: value - for key, value in original.items() - if key not in ("field1", "field2", "mappedTo") - }, - "mappedTo": 0, - "columns": [ - { - **{ - key: value - for key, value in original[axis].items() - if key != "mappedTo" - }, - "id": axis, - } - for axis in ("field1", "field2") - ], - } + assert field == {**original, "mappedTo": 0} assert view.schema["schema_version"] == "2" assert mlbundle.inspect(path).schema == mlbundle.load(path).schema == view.schema return path, field @@ -76,15 +53,9 @@ def test_series_export(series_bundle): ] -def old_shape(field): - for column in field.pop("columns"): - field[column.pop("id")] = {**column, "mappedTo": field["mappedTo"]} - - @pytest.mark.parametrize( "change", [ - old_shape, lambda f: f["columns"].reverse(), lambda f: f["columns"].pop(), lambda f: f["columns"].append(copy.deepcopy(f["columns"][0])), diff --git a/uv.lock b/uv.lock index c777b7d..2e6358f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14" resolution-markers = [ "sys_platform == 'win32'", @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "mlbundle" -version = "0.1.0a3" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -509,8 +509,8 @@ dev = [ requires-dist = [ { name = "catboost", marker = "extra == 'all'", specifier = ">=1.2.10" }, { name = "catboost", marker = "extra == 'catboost'", specifier = ">=1.2.10" }, - { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'all'", specifier = ">=0.2.1,<0.3" }, - { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'schema'", specifier = ">=0.2.1,<0.3" }, + { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'all'", specifier = ">=0.3.0,<0.4" }, + { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'schema'", specifier = ">=0.3.0,<0.4" }, { name = "onnx", marker = "extra == 'all'", specifier = ">=1.23.0" }, { name = "onnx", marker = "extra == 'onnx'", specifier = ">=1.23.0" }, { name = "onnxruntime", marker = "extra == 'all'", specifier = ">=1.30.0" }, @@ -539,15 +539,15 @@ dev = [ [[package]] name = "mlschema" -version = "0.2.1" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pandas" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/97/b3295985c4250525069b86bf0eee31c211b3d35cebee5b995f64a35dcb58/mlschema-0.2.1.tar.gz", hash = "sha256:59f819d677fa662f9c620938a6bef7fd093a11dad5872e507e5035da723cdb9e", size = 56763, upload-time = "2026-06-18T11:21:12.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/70/6d5c5e76dd5aff506364acf1e52b76cf93c72f3720a5767e1d96d50ad4e0/mlschema-0.3.0.tar.gz", hash = "sha256:6f201a71ec926f7891e149368f5ddd537652086cedcb2ae0e6cd68bee83800cc", size = 755411, upload-time = "2026-10-19T10:44:58.591Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/4a/c4b8f535f3ea8cb60af7ab65f30c8dd2a7363768465a63fcccee72cc265f/mlschema-0.2.1-py3-none-any.whl", hash = "sha256:26ae50b7861d7bd907c5117498d384d003d8e2925dcaa14335add3d0bd7101ba", size = 36852, upload-time = "2026-06-18T11:21:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/4230a97e095738c461dece0c2b2829e29291130892588bddd32d0b6cdf05/mlschema-0.3.0-py3-none-any.whl", hash = "sha256:49982ff201781a60b8e63951de6feac03fca1ce4b4c1b9e5a1b6a8895085438d", size = 37017, upload-time = "2026-10-19T10:44:57.276Z" }, ] [[package]]