diff --git a/.github/workflows/bindings-node.yml b/.github/workflows/bindings-node.yml
new file mode 100644
index 0000000..68d2b74
--- /dev/null
+++ b/.github/workflows/bindings-node.yml
@@ -0,0 +1,62 @@
+name: bindings-node
+on:
+ push:
+ branches: [main, feature/bindings]
+ paths:
+ - 'crates/engine/**'
+ - 'crates/js/**'
+ - 'bindings/node/**'
+ - 'bindings/CHROME_VERSION'
+ - '.github/workflows/bindings-node.yml'
+ pull_request:
+ paths:
+ - 'crates/engine/**'
+ - 'crates/js/**'
+ - 'bindings/node/**'
+ - 'bindings/CHROME_VERSION'
+ - '.github/workflows/bindings-node.yml'
+ workflow_dispatch:
+
+jobs:
+ smoke:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with: { node-version: "20" }
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: ". -> target"
+ - name: Install npm deps
+ working-directory: bindings/node
+ run: npm install
+ - name: Build native module
+ working-directory: bindings/node
+ run: npm run build:debug
+ - name: Run smoke tests
+ working-directory: bindings/node
+ run: npm test
+
+ e2e:
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with: { node-version: "20" }
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: ". -> target"
+ - name: Build + E2E
+ working-directory: bindings/node
+ env: { FOLIO_E2E: "1" }
+ run: |
+ npm install
+ npm run build:debug
+ npm test
diff --git a/.github/workflows/bindings-python.yml b/.github/workflows/bindings-python.yml
new file mode 100644
index 0000000..c0d24c5
--- /dev/null
+++ b/.github/workflows/bindings-python.yml
@@ -0,0 +1,62 @@
+name: bindings-python
+on:
+ push:
+ branches: [main, feature/bindings]
+ paths:
+ - 'crates/engine/**'
+ - 'crates/py/**'
+ - 'bindings/python/**'
+ - 'bindings/CHROME_VERSION'
+ - '.github/workflows/bindings-python.yml'
+ pull_request:
+ paths:
+ - 'crates/engine/**'
+ - 'crates/py/**'
+ - 'bindings/python/**'
+ - 'bindings/CHROME_VERSION'
+ - '.github/workflows/bindings-python.yml'
+ workflow_dispatch:
+
+jobs:
+ smoke:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with: { python-version: "3.10" }
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: ". -> target"
+ - name: Install build deps
+ run: pip install --upgrade pip maturin pytest
+ - name: Build wheel
+ run: maturin build --release --features chromium,libreoffice,chrome-fetch -m crates/py/Cargo.toml --out dist
+ - name: Install built wheel
+ shell: bash
+ run: pip install --find-links dist folio
+ - name: Run smoke tests
+ run: pytest -v bindings/python/tests/test_smoke.py
+
+ e2e:
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with: { python-version: "3.10" }
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: ". -> target"
+ - name: Install build deps
+ run: pip install --upgrade pip maturin pytest
+ - name: Install + run E2E
+ env: { FOLIO_E2E: "1" }
+ run: |
+ maturin develop --release --features chromium,chrome-fetch -m crates/py/Cargo.toml
+ pytest -v -k e2e bindings/python/tests/
diff --git a/Cargo.lock b/Cargo.lock
index b2f942f..0b66912 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -747,6 +747,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -840,6 +849,16 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "ctor"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
+dependencies = [
+ "quote",
+ "syn",
+]
+
[[package]]
name = "cucumber"
version = "0.21.1"
@@ -1082,15 +1101,20 @@ version = "0.1.0"
dependencies = [
"axum 0.8.9",
"chromiumoxide",
+ "dirs",
+ "flate2",
"futures-util",
"humantime-serde",
"image",
"lopdf",
"proptest",
"pulldown-cmark",
+ "reqwest 0.12.28",
"serde",
"serde_json",
+ "sha2",
"static_assertions",
+ "tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -1098,7 +1122,9 @@ dependencies = [
"tracing",
"tracing-subscriber",
"urlencoding",
+ "walkdir",
"which 7.0.3",
+ "zip",
]
[[package]]
@@ -1138,6 +1164,17 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "filetime"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1904,6 +1941,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "inflections"
version = "1.1.1"
@@ -1978,6 +2024,15 @@ dependencies = [
[[package]]
name = "js"
version = "0.1.0"
+dependencies = [
+ "engine",
+ "napi",
+ "napi-build",
+ "napi-derive",
+ "serde",
+ "serde_json",
+ "tokio",
+]
[[package]]
name = "js-sys"
@@ -2032,13 +2087,26 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
[[package]]
name = "libredox"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
+ "bitflags",
"libc",
+ "plain",
+ "redox_syscall 0.7.4",
]
[[package]]
@@ -2158,6 +2226,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -2228,6 +2305,66 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "napi"
+version = "2.16.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
+dependencies = [
+ "bitflags",
+ "ctor",
+ "napi-derive",
+ "napi-sys",
+ "once_cell",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
+name = "napi-build"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
+
+[[package]]
+name = "napi-derive"
+version = "2.16.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
+dependencies = [
+ "cfg-if",
+ "convert_case",
+ "napi-derive-backend",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "napi-derive-backend"
+version = "1.0.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
+dependencies = [
+ "convert_case",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "semver",
+ "syn",
+]
+
+[[package]]
+name = "napi-sys"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
+dependencies = [
+ "libloading",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -2411,7 +2548,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
- "redox_syscall",
+ "redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -2516,6 +2653,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
[[package]]
name = "png"
version = "0.18.1"
@@ -2529,6 +2672,12 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2692,6 +2841,92 @@ checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "py"
version = "0.1.0"
+dependencies = [
+ "engine",
+ "parking_lot",
+ "pyo3",
+ "pyo3-async-runtimes",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+]
+
+[[package]]
+name = "pyo3"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
+dependencies = [
+ "cfg-if",
+ "indoc",
+ "libc",
+ "memoffset",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-async-runtimes"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2529f0be73ffd2be0cc43c013a640796558aa12d7ca0aab5cc14f375b4733031"
+dependencies = [
+ "futures",
+ "once_cell",
+ "pin-project-lite",
+ "pyo3",
+ "tokio",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn",
+]
[[package]]
name = "quick-error"
@@ -2878,6 +3113,15 @@ dependencies = [
"bitflags",
]
+[[package]]
+name = "redox_syscall"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -3655,6 +3899,23 @@ dependencies = [
"windows",
]
+[[package]]
+name = "tar"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4156,6 +4417,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -4168,6 +4435,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "unindent"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -4922,6 +5195,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
[[package]]
name = "xz2"
version = "0.1.7"
diff --git a/Cargo.toml b/Cargo.toml
index dae59e0..8e02be2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -83,10 +83,17 @@ walkdir = "2"
lopdf = "0.34"
zip = "2"
+# Chrome auto-download
+sha2 = "0.10"
+flate2 = "1"
+tar = "0.4"
+dirs = "5"
+
# Bindings
pyo3 = { version = "0.22", features = ["extension-module"] }
-napi = { version = "2", features = ["napi8"] }
+napi = { version = "2", features = ["napi8", "tokio_rt", "serde-json"] }
napi-derive = "2"
+napi-build = "2"
[profile.release]
opt-level = 3
diff --git a/README.md b/README.md
index db61948..2d42938 100644
--- a/README.md
+++ b/README.md
@@ -1,154 +1,162 @@
-# Folio
-
-
+
+Folio
+
-
-
-
-
-
-
-
-
-
-
-
+ A Rust-native, Gotenberg-compatible PDF service β with a live operator console.
- A modern, Rust-native PDF generation engine
- True browser-grade fidelity β’ Gotenberg-compatible API β’ Memory safe
+
+
+
---
-## π Table of Contents
-
-- [What is Folio?](#what-is-folio)
-- [Why Folio?](#why-folio)
-- [Quick Start](#quick-start)
-- [Usage Modes](#usage-modes)
-- [Features](#features)
-- [Documentation](#documentation)
-- [Project Structure](#project-structure)
-- [Development](#development)
-- [Testing](#testing)
-- [Roadmap](#roadmap)
-- [Contributing](#contributing)
-- [License](#license)
+Folio converts **HTML, URLs, Markdown, and Office documents** into PDFs using
+real Chrome under the hood. It speaks the same HTTP API as
+[Gotenberg](https://github.com/gotenberg/gotenberg), so most existing
+clients can point at Folio with only a base-URL change.
+
+Unlike Gotenberg, Folio also runs as a **Rust library, a CLI, and a single
+binary** β and ships with a live operator console at `/_/` so you can see
+what your PDF service is actually doing without wiring up Grafana first.
+
+> **Status:** active. Core conversions and PDF ops are production-ready.
+> Webhook callback delivery, batch ZIP output, and a few advanced Chromium
+> options are still in progress β see the [feature comparison](./comparison.md).
---
-## What is Folio?
+## Why Folio
+
+- **Gotenberg-compatible.** Same routes (`/forms/chromium/*`,
+ `/forms/libreoffice/convert`, `/forms/pdfengines/*`), same multipart
+ contract. Drop-in for ~85% of workloads.
+- **Memory-safe.** Rust core; no GC pauses, no parser-level CVEs from
+ malformed inputs.
+- **Four ways to run it.** HTTP server, CLI, Rust library, Docker β pick
+ whichever fits your shape. The library is the source of truth; the
+ server and CLI are thin wrappers.
+- **Observability-first.** Prometheus metrics, OpenTelemetry traces, and
+ a built-in Svelte SPA at `/_/` showing live RPS, p95 latency,
+ per-engine health, concurrency, and active batches over SSE.
+- **Slim deployment targets.** Multi-stage Dockerfile produces full,
+ Chromium-only, LibreOffice-only, Cloud Run, and Lambda images.
+
+For the honest comparison against Gotenberg (what's parity, what's behind,
+what's ahead) read [`comparison.md`](./comparison.md).
-**Folio** (from Latin *folium*, meaning "leaf" or "sheet of paper") is a high-performance PDF generation engine built in Rust. It converts HTML, URLs, Markdown, and Office documents to PDF with **true browser-grade fidelity** by leveraging Chrome's rendering engine via the Chrome DevTools Protocol (CDP).
+---
-> Like a printer's folio marks the beginning of a new page, Folio marks a new chapter in document conversion technology.
+## 60-second quickstart
-### Key Highlights
+```bash
+# Run the server (Docker, full image)
+docker run --rm -p 3000:3000 ghcr.io/__deesh_reddy__/folio:latest
-- **True Browser Fidelity**: Renders using real Chrome/Chromium β full CSS3, JavaScript, Web Fonts support
-- **Gotenberg-Compatible**: Drop-in replacement for existing Gotenberg deployments
-- **Memory Safe**: Rust's compile-time guarantees prevent entire classes of bugs
-- **Multiple Interfaces**: HTTP API, CLI, Rust library, and language bindings (Python/Node.js)
-- **Self-Contained**: Library mode requires no external HTTP services
+# Convert a URL to PDF
+curl -X POST http://localhost:3000/forms/chromium/convert/url \
+ -F "url=https://example.com" \
+ -F "landscape=true" \
+ -o out.pdf
----
+# Open the operator console
+open http://localhost:3000/_/
+```
-## Why Folio?
+That's it. Same multipart contract for HTML, Markdown, Office, merge,
+split, watermark, etc.
-### Comparison Table
+---
-| Feature | **Folio** | Gotenberg | WeasyPrint | wkhtmltopdf |
-|---------|------------|-----------|-------------|-------------|
-| **Language** | Rust π¦ | Go | Python | C++ |
-| **Rendering** | Chrome (CDP) | Chrome | Custom engine | QtWebKit (2012) |
-| **Modern CSS** | β
Full | β
Full | β οΈ Limited | β Legacy |
-| **JavaScript** | β
Full V8 | β
Full | β None | β οΈ ES3 |
-| **Usage Modes** | 4 (Server/CLI/Lib/Bindings) | Server only | Library only | CLI only |
-| **Memory Safety** | β
Compile-time | GC | Runtime | Manual |
-| **Gotenberg API** | β
Compatible | β
Native | β | β |
-| **Screenshots** | β
Done | β
| β | β |
-| **Structured Logging** | β
Full (tracing) | β
(slog) | β | β |
-| **Prometheus Metrics** | β
`/prometheus/metrics` | β
| β | β |
-| **OpenTelemetry** | β
OTLP HTTP | β
| β | β |
-| **Process Supervision** | π§ In Progress | β
| β | β |
+## Install
-### Architecture Pattern
+| Surface | Command |
+|------------------|---------------------------------------------------------------|
+| Docker (full) | `docker pull ghcr.io/__deesh_reddy__/folio:latest` |
+| Docker (slim) | `docker pull ghcr.io/__deesh_reddy__/folio:latest-chromium` |
+| CLI (cargo) | `cargo install --path crates/cli` β `folio --help` |
+| Server (cargo) | `cargo run -p server -- serve --port 3000` |
+| Library | `folio-engine = { path = "crates/engine" }` in `Cargo.toml` |
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β USAGE MODES β
-β Server CLI Rust Lib Python Node.js β
-β β β β β β β
-β ββββββββββ΄ββββββββββ΄βββββββββββ΄ββββββββββ β
-β β β
-β ββββββββββββ΄βββββββββββ β
-β β engine β β Single source β
-β β β’ ChromiumEngine β of truth β
-β β β’ LibreOfficeEngine β β
-β β β’ PdfOperations β β
-β ββββββββββββ¬βββββββββββββ β
-β β β
-β ββββββββββββ΄βββββββββββ β
-β β Chrome (CDP) β β
-β ββββββββββββββββββββββββ β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-```
+**Prerequisites for non-Docker installs:** Rust 1.75+, Chrome/Chromium
+(auto-detected, or set `CHROME_PATH`), and optionally LibreOffice for
+Office conversion.
----
+### Embeddable bindings (v1: conversion)
-## Quick Start
+| Surface | Install |
+|---|---|
+| Python | `pip install folio` β see `bindings/python/README.md` |
+| Node.js | `npm install @folio/folio` β see `bindings/node/README.md` |
-### Prerequisites
+Both bindings auto-download a pinned Chrome on first use if no system
+Chrome is found. v1 supports HTML / URL / Markdown / Office β PDF;
+PDF ops and screenshots ship in v2 (spec:
+`docs/superpowers/specs/2026-05-01-bindings-design.md`).
-- **Rust** 1.75+ ([install](https://rustup.rs/))
-- **Chrome/Chromium** (auto-detected) or set `CHROME_PATH`
-- **LibreOffice** (optional, for Office document conversion)
+---
-### Option 1: HTTP Server (Gotenberg-Compatible)
+## HTTP API at a glance
-```bash
-# Build and run
-cargo run -p server -- serve --port 3000
+All routes are `POST` and accept multipart/form-data unless noted.
-# Or with Docker (full image β Chromium + LibreOffice)
-docker build --target folio -t folio:latest .
-docker run -p 3000:3000 folio:latest
+### Chromium (HTML / URL / Markdown β PDF or screenshot)
+```
+/forms/chromium/convert/{html,url,markdown}
+/forms/chromium/screenshot/{html,url,markdown}
+```
-# Convert URL to PDF
-curl -X POST http://localhost:3000/forms/chromium/convert/url \
- -F "url=https://example.com" \
- -F "landscape=true" \
- -o output.pdf
+### LibreOffice (100+ Office formats β PDF)
+```
+/forms/libreoffice/convert
```
-### Option 2: CLI
+### PDF operations
+```
+/forms/pdfengines/{merge,split,flatten,rotate,watermark,convert,encrypt}
+/forms/pdfengines/metadata/{read,write}
+/forms/pdfengines/bookmarks/{read,write}
+```
-```bash
-# Install
-cargo install --path crates/cli
+### Operational
+```
+GET /health β JSON health + per-engine status
+GET /version β plain text
+GET /prometheus/metrics β Prometheus text format
+GET /_/ β operator console (SPA)
+GET /_/sse β Server-Sent Events stream
+```
+
+For the gap analysis vs Gotenberg, see [`comparison.md`](./comparison.md).
-# Convert HTML to PDF
-folio convert --html index.html --output out.pdf
+---
-# Convert URL to PDF
-folio convert --url https://example.com --output out.pdf
+## CLI
-# Batch conversion
-folio batch --input-dir ./docs/ --output-dir ./pdfs/
+```bash
+folio convert --html index.html --output out.pdf
+folio convert --url https://example.com --output out.pdf
+folio convert --markdown README.md --output out.pdf
+folio convert --office report.docx --output out.pdf
+
+folio merge a.pdf b.pdf c.pdf --output combined.pdf
+folio split input.pdf --mode uniform --span 1 --output-dir ./pages/
+folio flatten input.pdf --output flat.pdf
+folio rotate input.pdf --angle 90 --output rotated.pdf
+folio metadata read input.pdf
+folio metadata write input.pdf '{"Title":"Q2 Review"}'
```
-### Option 3: Rust Library
+Shell completions: `folio completion zsh > ~/.zfunc/_folio`.
-```toml
-# Cargo.toml
-[dependencies]
-folio-engine = { path = "crates/engine" }
-```
+---
+
+## Library
```rust
use engine::ChromiumEngine;
@@ -156,459 +164,167 @@ use engine::ChromiumEngine;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let engine = ChromiumEngine::launch().await?;
- let pdf = engine.html_to_pdf("Hello World
", None, &Default::default(), &Default::default()).await?;
- std::fs::write("output.pdf", pdf)?;
+ let pdf = engine
+ .html_to_pdf("Hello
", None, &Default::default(), &Default::default())
+ .await?;
+ std::fs::write("out.pdf", pdf)?;
Ok(())
}
```
-### Option 4: Docker Compose (Development)
-
-```bash
-# Copy example environment file
-cp .env.example .env
-
-# Start Folio with all dependencies
-make run
-
-# Run tests
-make test-integration
-
-# Stop
-make stop
-```
+The engine crate has zero dependency on `axum` or `tower` β it's the same
+code path the server uses, just without an HTTP layer in front.
---
-## Usage Modes
+## Operator console
-### 1. Server Mode (HTTP API)
+`GET /_/` serves a Svelte SPA driven by Server-Sent Events. In one screen:
-Gotenberg-compatible REST API for document conversion:
+- **Ticker:** RPS, p95 latency, error %, in-flight count
+- **Routes table:** per-endpoint p50 / p95 / p99, error %, load %
+- **Engines:** Chromium / LibreOffice up-down + restart count
+- **Concurrency grid:** active vs cap, with warn/crit thresholds
+- **Throughput strip:** 30-min RPS + p95 trend with SLA overlay
+- **Resources:** CPU % and memory MB
+- **Batches:** progress + per-item state for active batches
+- **Logs:** last 20 requests, last 10 errors
-| Endpoint | Method | Input | Output |
-|----------|--------|-------|--------|
-| `/forms/chromium/convert/html` | POST | HTML file | PDF |
-| `/forms/chromium/convert/url` | POST | URL | PDF |
-| `/forms/chromium/convert/markdown` | POST | Markdown | PDF |
-| `/forms/chromium/screenshot/html` | POST | HTML | PNG/JPEG/WebP |
-| `/forms/libreoffice/convert` | POST | Office docs | PDF |
-| `/forms/pdfengines/merge` | POST | PDFs | Merged PDF |
-| `/forms/pdfengines/split` | POST | PDF | Split PDFs |
-| `/health` | GET | - | Health status |
+This is the cleanest lead Folio has over Gotenberg today; it's where the
+last 30 commits have lived. If you've ever bolted Grafana onto Gotenberg
+just to see whether it's healthy β this replaces that step.
-See [API Documentation](./docs/gotenberg-spec.md) for full details.
+---
-### 2. CLI Mode
+## Configuration
-Command-line interface for batch operations and scripting:
+Common flags (every flag is also `FOLIO_*` env-overridable):
```bash
-# Convert various formats
-folio convert --html file.html --output out.pdf
-folio convert --url https://example.com --output out.pdf
-folio convert --markdown README.md --output readme.pdf
-
-# PDF operations
-folio merge --output combined.pdf file1.pdf file2.pdf
-folio split input.pdf --output-dir ./split/
-folio flatten input.pdf --output flat.pdf
-folio metadata read input.pdf
-```
-
-### 3. Library Mode (Rust)
-
-Use Folio as a Rust library in your applications:
-
-```rust
-// HTML to PDF
-let engine = ChromiumEngine::launch().await?;
-let pdf = engine.html_to_pdf(html, None, &opts, &ctx).await?;
-
-// URL to PDF
-let pdf = engine.url_to_pdf("https://example.com", &opts, &ctx).await?;
-
-// Markdown to PDF
-let pdf = engine.markdown_to_pdf(markdown, &opts, &ctx).await?;
-```
-
-### 4. Language Bindings
-
-**Python** ([Planned]):
-```python
-import folio
-
-engine = folio.ChromiumEngine()
-pdf = engine.html_to_pdf("Hello
")
-```
-
-**Node.js** ([Planned]):
-```javascript
-const folio = require('folio');
-const engine = new folio.ChromiumEngine();
-const pdf = await engine.htmlToPdf('Hello
');
+folio-server serve \
+ --host 0.0.0.0 --port 3000 \
+ --concurrency 8 \
+ --max-body-bytes 52428800 \ # 50 MiB
+ --request-timeout 120s \
+ --chrome /usr/bin/google-chrome --no-sandbox \
+ --soffice /usr/bin/soffice \
+ --log-level info --log-format json \
+ --api-basic-auth-username admin --api-basic-auth-password secret \
+ --otel-enabled --otel-endpoint http://localhost:4318/v1/traces
```
----
+Run `folio-server serve --help` for the full flag reference.
-## Features
-
-### β
Implemented
-
-- **HTML/URL to PDF**: Full Chrome rendering with print CSS support
-- **Markdown to PDF**: GitHub Flavored Markdown with syntax highlighting
-- **Office Documents**: Convert 100+ formats via LibreOffice (DOC, DOCX, PPT, XLS, ODT, etc.)
-- **PDF Operations**: Merge, split, flatten, rotate, watermark
-- **PDF Metadata**: Read/write PDF metadata
-- **Gotenberg Compatibility**: Drop-in API replacement
-- **Health Checks**: `/health` endpoint with engine status
-- **Concurrent Rendering**: Thread-safe browser instance sharing
-- **Screenshots**: URL/HTML/Markdown to PNG/JPEG/WebP
-- **BDD Testing**: Port Gotenberg's Gherkin scenarios to Rust
-- **Webhook System**: Async job dispatch with retry, full engine integration (spec 15)
-- **Structured Logging**: Context-aware logs with request_id, engine type, duration (text/JSON formats)
-- **Prometheus Metrics**: `/prometheus/metrics` endpoint with conversion, queue, and engine metrics
-
-### π§ In Progress / Partially Done
-
-- **Advanced Wait Conditions**: `skipNetworkIdleEvent`, `failOnResourceLoadingFailed`, etc. (spec 36)
-- **Advanced LibreOffice Fields**: 30+ missing export options (spec 37)
-- **Full CLI Flag Parity**: Many Gotenberg flags still missing (spec 39)
-- **Actionable Errors**: Structured error responses, room for enhancement (spec 44)
-- **BDD Test Suite**: Framework exists, scenario coverage incomplete (spec 50)
-- **Batch API**: CLI batch works; server-side bulk endpoint pending (spec 50-batch)
-- **Health Dashboard**: JSON `/health` works; visual HTML dashboard pending (spec 51)
-
-### β Not Started (Spec-Only)
-
-- **Python / Node.js Bindings**: Empty placeholders only (specs 40, 41)
-- **Multi-Backend PDF Engines**: qpdf, pdfcpu, pdftk backends (spec 38)
-- **Special Features**: TLS, auth, cloud-run, remote URL download (spec 40-special)
-- **Smart PDF Optimiser**: Automatic bloat detection & compression (spec 42)
-- **Font Doctor**: Font rendering diagnostics (spec 43)
-- **Live Preview**: HTMLβimage debug preview (spec 45)
-- **PDF Size Estimator**: Pre-flight size prediction (spec 46)
-- **One-Command Install**: `curl | bash` installer (spec 47)
-- **Interactive Docs**: Built-in `/docs` API explorer (spec 48)
-- **Template Library**: Pre-built document templates (spec 49)
-
-> **Note:** This README is a high-level overview. For a ground-truth audit of what is actually built vs. spec claims, see [`docs/implementation-status.md`](./docs/implementation-status.md). The `20-missing-features-roadmap.md` spec is currently stale and should not be relied upon for current status.
+**TLS is intentionally not handled in-process.** Put nginx, Caddy, or
+envoy in front. Cert rotation, OCSP stapling, and ALPN are not things
+Folio is positioned to do better than they do.
---
-## Documentation
-
-### Core Documentation
-
-| Document | Description |
-|----------|-------------|
-| [Technical Specification](./docs/proposal.md) | Full architecture and design |
-| [Gotenberg API Spec](./docs/gotenberg-spec.md) | API compatibility details |
-| [Gap Analysis](./docs/gap-analysis.md) | Research findings |
-
-### Specs (Implementation Guides)
-
-| Spec | Description | Status |
-|------|-------------|--------|
-| [00-overview](./docs/specs/00-overview.md) | Spec system overview & conventions | π Reference |
-| [10-engine-types](./docs/specs/10-engine-types.md) | Core types, errors, options | β
Done |
-| [11-engine-chromium](./docs/specs/11-engine-chromium.md) | Chromium engine (HTML/URL/MarkdownβPDF + screenshots) | β
Done |
-| [12-engine-libreoffice](./docs/specs/12-engine-libreoffice.md) | LibreOffice engine (OfficeβPDF) | β
Done |
-| [13-engine-pdfops](./docs/specs/13-engine-pdfops.md) | PDF operations (merge, split, flatten, metadata, watermark, rotate) | β
Done |
-| [14-engine-pdfa](./docs/specs/14-engine-pdfa.md) | PDF/A & PDF/UA conformance conversion | β
Done |
-| [15-webhook](./docs/specs/15-webhook.md) | Async webhook callback system | π§ Partially Done |
-| [16-bookmarks](./docs/specs/16-bookmarks.md) | PDF bookmarks/outline read & write | β
Done |
-| [17-watermark](./docs/specs/17-watermark.md) | PDF watermark & stamp overlay | β
Done *(via spec 13)* |
-| [18-screenshot](./docs/specs/18-screenshot.md) | Chromium screenshot API (PNG/JPEG/WebP) | β
Done *(via spec 11)* |
-| [19-encrypt](./docs/specs/19-encrypt.md) | PDF encryption & password protection | β
Done |
-| [20-cli](./docs/specs/20-cli.md) | Command-line interface (`folio` binary) | β
Done |
-| [20-bdd-testing](./docs/specs/20-bdd-testing.md) | BDD test strategy | π§ Partially Done |
-| [20-missing-features-roadmap](./docs/specs/20-missing-features-roadmap.md) | Feature parity roadmap vs Gotenberg | π Reference |
-| [30-server](./docs/specs/30-server.md) | HTTP server (Gotenberg-compatible API) | β
Done |
-| [36-chromium-wait-conditions](./docs/specs/36-chromium-wait-conditions.md) | Advanced wait conditions & options | π§ Partially Done |
-| [37-libreoffice-advanced](./docs/specs/37-libreoffice-advanced.md) | Advanced LibreOffice form fields | π§ Partially Done |
-| [38-pdfengines-backends](./docs/specs/38-pdfengines-backends.md) | Multi-backend support (qpdf, pdfcpu, pdftk) | β Not Done |
-| [39-config-flags](./docs/specs/39-config-flags.md) | Full Gotenberg CLI flag parity | π§ Partially Done |
-| [40-bindings-py](./docs/specs/40-bindings-py.md) | Python bindings (`py` crate) | β Not Done *(placeholder)* |
-| [40-special-features](./docs/specs/40-special-features.md) | TLS, auth, cloud-run, remote URL download | β Not Done |
-| [41-bindings-js](./docs/specs/41-bindings-js.md) | Node.js bindings (`js` crate) | β Not Done *(placeholder)* |
-| [41-github-issues-analysis](./docs/specs/41-github-issues-analysis.md) | User pain-point research from GitHub issues | π Research |
-| [42-smart-pdf-optimiser](./docs/specs/42-smart-pdf-optimiser.md) | Automatic PDF size optimisation | β Not Done |
-| [43-font-doctor](./docs/specs/43-font-doctor.md) | Font rendering diagnostics & fixes | β Not Done |
-| [44-crystal-clear-errors](./docs/specs/44-crystal-clear-errors.md) | Actionable error messages (replace generic 500s) | π§ Partially Done |
-| [45-live-preview-mode](./docs/specs/45-live-preview-mode.md) | Live HTMLβimage preview for debugging | β Not Done |
-| [46-pdf-size-estimator](./docs/specs/46-pdf-size-estimator.md) | Pre-flight PDF size prediction | β Not Done |
-| [47-one-command-install](./docs/specs/47-one-command-install.md) | Frictionless install (`curl | bash`) | β Not Done |
-| [48-interactive-docs](./docs/specs/48-interactive-docs.md) | Built-in API explorer at `/docs` | β Not Done |
-| [49-template-library](./docs/specs/49-template-library.md) | Pre-built document templates | β Not Done |
-| [50-batch-api](./docs/specs/50-batch-api.md) | Bulk conversion API (100+ docs in one request) | π§ Partially Done *(CLI batch only)* |
-| [50-testing-bdd](./docs/specs/50-testing-bdd.md) | BDD integration test suite (GherkinβRust) | π§ Partially Done |
-| [51-health-dashboard](./docs/specs/51-health-dashboard.md) | Visual health dashboard beyond JSON `/health` | π§ Partially Done |
-
-**Legend:** `β
Done` = fully implemented & tested. `π§ Partially Done` = core working, gaps remain. `β Not Done` = spec only, no code. `π Reference` = meta-doc or research, no code expected.
-
-### API Reference
-
-- **Chromium Routes**: `/forms/chromium/*` (convert HTML/URL/Markdown, screenshots)
-- **LibreOffice Routes**: `/forms/libreoffice/*` (convert Office docs)
-- **PDF Engine Routes**: `/forms/pdfengines/*` (merge, split, flatten, etc.)
+## Docker variants
----
+Single `Dockerfile`, multiple `--target` stages β pick the smallest one
+that does what you need.
-## Project Structure
+| Target | Contains | Use case |
+|------------------------------|----------------------|----------------------|
+| `folio` | Chromium + LO | Default |
+| `folio-chromium` | Chromium | HTML/URL/Markdown only (~30% smaller) |
+| `folio-libreoffice` | LO | Office docs only (~40% smaller) |
+| `folio-cloudrun` | Full + Cloud Run env | Google Cloud Run |
+| `folio-lambda` | Full + Lambda Web Adapter | AWS Lambda |
+| `folio-{cloudrun,lambda}-{chromium,libreoffice}` | Slim + platform | Mix-and-match |
-```
-folio/
-βββ Cargo.toml # Workspace definition
-βββ README.md # This file
-βββ Dockerfile # Single file, 9 named --target variants (see Docker section)
-βββ Dockerfile.test # Test environment (poppler, JRE, verapdf)
-βββ docker-compose.yml # Development environment
-βββ Makefile # Build/test/docker automation
-βββ .env.example # Configuration template
-β
-βββ crates/
-β βββ engine/ # Core PDF generation engine
-β β βββ src/
-β β β βββ chromium/ # Chrome/Chromium integration
-β β β β βββ launch.rs # Browser discovery & launch
-β β β β βββ render.rs # HTML/URL β PDF
-β β β β βββ screenshot.rs # Screenshots (β
)
-β β β βββ libreoffice/ # LibreOffice integration
-β β β βββ pdfops/ # PDF manipulation
-β β βββ Cargo.toml
-β β
-β βββ server/ # HTTP server (Gotenberg-compatible)
-β β βββ src/
-β β β βββ routes/ # API route handlers
-β β β βββ app.rs # Router configuration
-β β βββ tests/ # Integration tests
-β β
-β βββ cli/ # Command-line interface
-β β βββ src/commands/ # CLI subcommands
-β β
-β
-βββ docs/
-β βββ proposal.md # Technical specification
-β βββ gotenberg-spec.md # Gotenberg API analysis
-β βββ gap-analysis.md # Research findings
-β βββ assets/ # Images, logos
-β βββ specs/ # Implementation specs (32 files, see table above)
-β
-βββ crates/*/tests/ # Crate-local tests (unit + integration)
- βββ server/tests/bdd/ # BDD integration tests
+```bash
+docker build --target folio-chromium -t folio:chromium .
+make docker-push-all DOCKER_REGISTRY=ghcr.io/me VERSION=1.0.0
```
---
-## Development
-
-### Building from Source
-
-```bash
-# Clone the repository
-git clone https://github.com/yourusername/folio.git
-cd folio
-
-# Build all crates
-cargo build --release
+## Where things stand
-# Run tests
-cargo test
+A short, honest scorecard. The full version is [`comparison.md`](./comparison.md).
-# Run with specific features
-cargo run -p server -- serve --help
-```
-
-### Docker Image Variants
+**Ready to use:**
+HTML/URL/MarkdownβPDF Β· OfficeβPDF Β· screenshots Β· merge Β· split Β· flatten Β·
+rotate Β· watermark Β· metadata Β· bookmarks Β· encrypt Β· PDF/A & PDF/UA Β·
+Basic Auth Β· Prometheus Β· OpenTelemetry Β· operator console Β· CLI Β· Rust
+library Β· multi-target Docker.
-All variants are built from a single `Dockerfile` using named `--target` stages, following Gotenberg's pattern. Each platform-specific variant (Cloud Run, Lambda) is a thin layer on top of the base variant β just environment variables.
+**In progress:**
+Webhook callback delivery (scaffold ready, delivery TODO) Β·
+batch API ZIP/merge output (endpoints + worker exist) Β·
+advanced Chromium wait/fail conditions (`waitForSelector`, `failOn*`) Β·
+long tail of LibreOffice export filters Β· `embed` and full `stamp` routes.
-| Target | Tag | Description |
-|--------|-----|-------------|
-| `folio` | `latest`, `vX.Y.Z` | Full: Chromium + LibreOffice |
-| `folio-chromium` | `latest-chromium` | Chromium only (~30% smaller) |
-| `folio-libreoffice` | `latest-libreoffice` | LibreOffice only (~40% smaller) |
-| `folio-cloudrun` | `latest-cloudrun` | Full + Google Cloud Run env vars |
-| `folio-cloudrun-chromium` | `latest-chromium-cloudrun` | Chromium + Cloud Run |
-| `folio-cloudrun-libreoffice` | `latest-libreoffice-cloudrun` | LibreOffice + Cloud Run |
-| `folio-lambda` | `latest-lambda` | Full + [Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) |
-| `folio-lambda-chromium` | `latest-chromium-lambda` | Chromium + Lambda |
-| `folio-lambda-libreoffice` | `latest-libreoffice-lambda` | LibreOffice + Lambda |
+**Deliberate gaps:**
+TLS in-process (use a reverse proxy) Β· OAuth/JWT/RBAC (use a reverse
+proxy) Β· workflow/DAG engine on top of batch (out of scope).
-```bash
-# Build a specific variant
-docker build --target folio-chromium -t myrepo/folio:chromium .
+**Empty placeholders (will be removed if not built):**
+Python bindings (`crates/py/`), Node bindings (`crates/js/`).
-# Build + push all 9 variants
-make docker-push-all DOCKER_REGISTRY=myrepo/folio VERSION=1.0.0
+---
-# Run with Docker Compose (default: full image)
-docker compose up folio
+## Documentation
-# Run Chromium-only profile
-docker compose --profile chromium up folio-chromium
-```
+- [`comparison.md`](./comparison.md) β in-depth audit vs Gotenberg
+- [`docs/markdown-plus.md`](./docs/markdown-plus.md) β proposed
+ enhanced Markdown route (front-matter, math, mermaid, themes)
-### Development Commands
-
-| Command | Description |
-|---------|-------------|
-| `make docker-build` | Build full Docker image |
-| `make docker-build-all` | Build all 9 variants |
-| `make docker-push-all` | Build and push all variants |
-| `make run` | Start Folio via Docker Compose |
-| `make test-unit` | Run unit tests |
-| `make test-integration` | Run integration tests (requires Chrome) |
-| `make fmt` | Format code |
-| `make lint` | Lint with Clippy |
-| `make check` | Run format + lint + unit tests |
-| `make clean` | Clean build artifacts |
-
-### Environment Variables
-
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `CHROME_PATH` | Path to Chrome/Chromium executable | Auto-detected |
-| `LIBREOFFICE_PATH` | Path to LibreOffice (soffice) | Auto-detected |
-| `RUST_LOG` | Log level (trace, debug, info, warn, error) | `info` |
-| `FOLIO_PORT` | Server port | `3000` |
-| `FOLIO_CONCURRENCY` | Max concurrent renders | CPU count |
-| `FOLIO_OTEL_ENABLED` | Enable OpenTelemetry trace export | `false` |
-| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP trace endpoint | `http://localhost:4318/v1/traces` |
+> **Note on specs.** The previous 32-file `docs/specs/` tree has been
+> archived to [`docs/specs-archive-2026-05-01.zip`](./docs/specs-archive-2026-05-01.zip).
+> Fresh, better-organised contributor-facing specs are being written and
+> will reappear under `docs/` shortly.
---
-## Testing
-
-### Test Structure
-
-```
-tests/
-βββ unit/ # Unit tests (cargo test --lib)
-βββ integration/ # BDD integration tests (π§)
-β βββ scenarios/ # Test scenarios (ported from Gotenberg)
-β βββ common/ # Test helpers
-β βββ testdata/ # Test fixtures
-βββ e2e/ # End-to-end tests
-```
-
-### Running Tests
+## Development
```bash
-# Unit tests (no Chrome required)
-cargo test --lib
+git clone https://github.com/__deesh_reddy__/folio.git && cd folio
-# Integration tests (skip gracefully if deps missing)
-cargo test -p server --test bdd
-
-# E2E tests (skip gracefully if deps missing)
-cargo test -p server --test e2e
-
-# All tests (skip gracefully if deps missing)
-cargo test -- --test-threads=1
-
-# All tests with Docker
-make docker-test
+cargo build --release # build everything
+cargo test # unit + integration (skips gracefully if Chrome missing)
+make check # fmt + clippy + unit tests (run before PRs)
+make run # docker-compose up, full image
+make test-integration # BDD scenarios in Docker
```
-### Test Coverage
+| Command | What it does |
+|-------------------------|---------------------------------------|
+| `make docker-build` | Build full image |
+| `make docker-build-all` | Build all 9 image variants |
+| `make test-unit` | `cargo test --lib` |
+| `make test-integration` | BDD + e2e in container |
+| `make fmt` / `make lint`| `cargo fmt` / `cargo clippy` |
-We're porting Gotenberg's comprehensive BDD test suite:
-
-- β
Unit tests: 50+ test cases
-- π§ Integration tests: BDD framework with 25+ feature files (scenario pass rate unverified)
-- β
E2E tests: Server + CLI smoke tests
-
-See [BDD Testing Spec](./docs/specs/50-testing-bdd.md) for details.
-
----
-
-## Roadmap
-
-### Phase 1: Core Features β
-- [x] HTML/URL/Markdown β PDF (Chromium) β spec 11
-- [x] Office documents β PDF (LibreOffice) β spec 12
-- [x] PDF operations (merge, split, flatten, rotate, watermark) β spec 13
-- [x] PDF metadata read/write β spec 13
-- [x] Gotenberg-compatible API β spec 30
-- [x] Screenshots (HTML/URL/Markdown β PNG/JPEG/WebP) β spec 11 / 18
-- [x] Structured Logging (tracing with text/JSON formats)
-- [x] Prometheus Metrics (`/prometheus/metrics` endpoint)
-- [x] OpenTelemetry Traces (OTLP HTTP exporter)
-- [x] CLI (`folio` binary) β spec 20
-
-### Phase 2: Advanced Engine Features π§
-- [x] PDF/A & PDF/UA conformance conversion β spec 14
-- [x] PDF bookmarks read/write β spec 16
-- [x] PDF encryption & password protection β spec 19
-- [ ] Advanced Chromium wait conditions β spec 36
-- [ ] Advanced LibreOffice form fields β spec 37
-- [ ] Multi-backend PDF engines (qpdf, pdfcpu, pdftk) β spec 38
-
-### Phase 3: Server & Infrastructure π§
-- [ ] Webhook system with retry β spec 15
-- [ ] Full CLI flag parity with Gotenberg β spec 39
-- [ ] Batch API (server-side bulk conversion) β spec 50-batch
-- [ ] Actionable error messages β spec 44
-- [ ] Visual health dashboard β spec 51
-
-### Phase 4: Bindings & Ecosystem β
-- [ ] Python bindings (`py` crate) β spec 40
-- [ ] Node.js bindings (`js` crate) β spec 41
-- [ ] TLS, auth, cloud-run, remote URL download β spec 40-special
-
-### Phase 5: Unique Folio Features β
-- [ ] Smart PDF optimiser β spec 42
-- [ ] Font doctor / diagnostics β spec 43
-- [ ] Live preview mode β spec 45
-- [ ] PDF size estimator β spec 46
-- [ ] One-command install (`curl | bash`) β spec 47
-- [ ] Interactive API docs (`/docs`) β spec 48
-- [ ] Template library β spec 49
-
-See [Full Roadmap](./docs/specs/20-missing-features-roadmap.md) and detailed specs in [docs/specs/](./docs/specs/) for planning.
+**Useful env vars:** `CHROME_PATH`, `LIBREOFFICE_PATH`, `RUST_LOG`,
+`FOLIO_PORT`, `FOLIO_CONCURRENCY`, `OTEL_EXPORTER_OTLP_ENDPOINT`.
---
## Contributing
-Contributions are welcome! Please read our [contributing guidelines](./CONTRIBUTING.md) before submitting a PR.
-
-### Quick Contribution Guide
+PRs welcome. Three things that make a PR easy to land:
-1. Fork the repository
-2. Create a feature branch (`git checkout -b feature/amazing-feature`)
-3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
-4. Push to the branch (`git push origin feature/amazing-feature`)
-5. Open a Pull Request
+1. `make check` passes locally.
+2. Conventional Commits style (`feat:`, `fix:`, `docs:`, `chore:`).
+3. One feature or fix per PR β split mixed work.
-### Development Workflow
-
-- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages
-- Ensure `make check` passes before submitting PR
-- Add tests for new functionality
-- Update documentation as needed
-- Keep PRs focused on a single feature/fix
+For larger changes, open an issue first so we can agree on the shape
+before code.
---
-## Acknowledgments
-
-- **[Gotenberg](https://github.com/gotenberg/gotenberg)** - The original PDF generation API that inspired this project
-- **[chromiumoxide](https://github.com/mattsse/chromiumoxide)** - Chrome DevTools Protocol client for Rust
-- **[lopdf](https://github.com/Hopding/lopdf)** - Pure Rust PDF manipulation library
-- **[Axum](https://github.com/tokio-rs/axum)** - Ergonomic HTTP server framework
+## Acknowledgements
----
+- [Gotenberg](https://github.com/gotenberg/gotenberg) β the API contract Folio implements
+- [chromiumoxide](https://github.com/mattsse/chromiumoxide) β Chrome DevTools Protocol client
+- [lopdf](https://github.com/J-F-Liu/lopdf) β pure-Rust PDF manipulation
+- [axum](https://github.com/tokio-rs/axum) β HTTP server
## License
-Folio is licensed under the MIT License - see [LICENSE](LICENSE) for details.
-
----
-
-
- Built with β€οΈ in Rust π¦
- Folio: A new page in PDF generation.
-
+MIT. See [LICENSE](./LICENSE).
diff --git a/bindings/CHROME_VERSION b/bindings/CHROME_VERSION
new file mode 100644
index 0000000..ba102db
--- /dev/null
+++ b/bindings/CHROME_VERSION
@@ -0,0 +1 @@
+131.0.6778.204
\ No newline at end of file
diff --git a/bindings/README.md b/bindings/README.md
new file mode 100644
index 0000000..b8d5e0d
--- /dev/null
+++ b/bindings/README.md
@@ -0,0 +1,15 @@
+# Folio bindings
+
+This directory ships Folio as embeddable libraries.
+
+- `bindings/python/` β maturin project producing the `folio` PyPI package.
+- `bindings/node/` β napi-rs project producing the `@folio/folio` npm package.
+- `bindings/fixtures/` β shared HTML/Office fixtures used by tests.
+- `CHROME_VERSION` β pinned Chrome-for-Testing version. Bumped per release.
+
+The Rust glue lives in `crates/py` and `crates/js`. The Folio engine
+itself is unchanged; bindings reuse `crates/engine` plus the new
+`engine::chrome_fetch` module.
+
+See `docs/superpowers/specs/2026-05-01-bindings-design.md` for the full
+design (v1 + v2).
diff --git a/bindings/fixtures/hello.html b/bindings/fixtures/hello.html
new file mode 100644
index 0000000..66c352f
--- /dev/null
+++ b/bindings/fixtures/hello.html
@@ -0,0 +1 @@
+folio e2e
diff --git a/bindings/node/.gitignore b/bindings/node/.gitignore
new file mode 100644
index 0000000..4505758
--- /dev/null
+++ b/bindings/node/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+*.node
+dist/
+*.log
+_native.js
+_native.d.ts
diff --git a/bindings/node/README.md b/bindings/node/README.md
new file mode 100644
index 0000000..7a10d64
--- /dev/null
+++ b/bindings/node/README.md
@@ -0,0 +1,14 @@
+# @folio/folio
+
+Rust-native PDF conversion, embeddable in Node. See spec at
+`docs/superpowers/specs/2026-05-01-bindings-design.md`.
+
+ npm install @folio/folio
+
+ import { Folio } from '@folio/folio';
+ const f = await Folio.create();
+ try {
+ const pdf = await f.htmlToPdf('hi
');
+ } finally {
+ await f.close();
+ }
diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts
new file mode 100644
index 0000000..c978e07
--- /dev/null
+++ b/bindings/node/index.d.ts
@@ -0,0 +1,31 @@
+/* tslint:disable */
+/* eslint-disable */
+
+/* auto-generated by NAPI-RS */
+
+/** Options passed to [`Folio::create`]. */
+export interface CreateOptions {
+ /** Which engines to enable. Defaults to `["chromium", "office"]`. */
+ engines?: Array
+ /** Explicit path to a Chrome/Chromium executable. */
+ chromePath?: string
+ /** Automatically download Chrome if no system Chrome is found. */
+ autoDownloadChrome?: boolean
+ /** Directory used to cache downloaded Chrome binaries. */
+ chromeCacheDir?: string
+}
+/** Async Folio client that wraps the PDF/document engines. */
+export declare class Folio {
+ /** Create a new Folio instance, launching the requested engines. */
+ static create(opts?: CreateOptions | undefined | null): Promise
+ /** Convert an HTML string to a PDF buffer. */
+ htmlToPdf(html: string, options?: Json | undefined | null): Promise
+ /** Convert a URL to a PDF buffer. */
+ urlToPdf(url: string, options?: Json | undefined | null): Promise
+ /** Convert a Markdown string to a PDF buffer. */
+ markdownToPdf(md: string, options?: Json | undefined | null): Promise
+ /** Convert an office document at `path` to a PDF buffer. */
+ officeToPdf(path: string, options?: Json | undefined | null): Promise
+ /** Shut down the Folio instance and release resources. */
+ close(): Promise
+}
diff --git a/bindings/node/index.js b/bindings/node/index.js
new file mode 100644
index 0000000..ac7a4b5
--- /dev/null
+++ b/bindings/node/index.js
@@ -0,0 +1,60 @@
+'use strict';
+
+const { Folio: NativeFolio } = require('./_native.js');
+
+class FolioError extends Error { constructor(m){ super(m); this.name='FolioError'; } }
+class ChromeNotFoundError extends FolioError { constructor(m){ super(m); this.name='ChromeNotFoundError'; } }
+class ChromeFetchError extends FolioError { constructor(m){ super(m); this.name='ChromeFetchError'; } }
+class ChromiumError extends FolioError { constructor(m){ super(m); this.name='ChromiumError'; } }
+class OfficeError extends FolioError { constructor(m){ super(m); this.name='OfficeError'; } }
+class EngineDisabledError extends FolioError { constructor(m){ super(m); this.name='EngineDisabledError'; } }
+class TimeoutError extends FolioError { constructor(m){ super(m); this.name='TimeoutError'; } }
+class ValidationError extends FolioError { constructor(m){ super(m); this.name='ValidationError'; } }
+
+const tagMap = {
+ ChromeNotFound: ChromeNotFoundError,
+ ChromeFetch: ChromeFetchError,
+ Chromium: ChromiumError,
+ Office: OfficeError,
+ EngineDisabled: EngineDisabledError,
+ Timeout: TimeoutError,
+ Validation: ValidationError,
+};
+
+function decorate(err) {
+ if (!(err instanceof Error)) return err;
+ const m = err.message || '';
+ const match = m.match(/^\[(\w+)\]\s*(.*)$/);
+ if (!match) return err;
+ const Cls = tagMap[match[1]];
+ if (!Cls) return err;
+ const decorated = new Cls(match[2]);
+ decorated.cause = err;
+ return decorated;
+}
+
+function wrapMethod(fn) {
+ return async function(...args) {
+ try { return await fn.apply(this, args); }
+ catch (e) { throw decorate(e); }
+ };
+}
+
+class Folio {
+ constructor(inner) { this._inner = inner; }
+ static async create(opts) {
+ try {
+ const inner = await NativeFolio.create(opts);
+ return new Folio(inner);
+ } catch (e) { throw decorate(e); }
+ }
+}
+for (const m of ['htmlToPdf', 'urlToPdf', 'markdownToPdf', 'officeToPdf', 'close']) {
+ Folio.prototype[m] = wrapMethod(function(...args) { return this._inner[m](...args); });
+}
+
+module.exports = {
+ Folio,
+ FolioError, ChromeNotFoundError, ChromeFetchError, ChromiumError,
+ OfficeError, EngineDisabledError, TimeoutError, ValidationError,
+};
diff --git a/bindings/node/package-lock.json b/bindings/node/package-lock.json
new file mode 100644
index 0000000..3e25ac0
--- /dev/null
+++ b/bindings/node/package-lock.json
@@ -0,0 +1,1890 @@
+{
+ "name": "@folio/folio",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@folio/folio",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@napi-rs/cli": "^2.18.0",
+ "@types/node": "^20.0.0",
+ "vitest": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/cli": {
+ "version": "2.18.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz",
+ "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi": "scripts/index.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.10",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
+ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.39",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
+ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
+ "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "chai": "^4.3.10"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz",
+ "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "1.6.1",
+ "p-limit": "^5.0.0",
+ "pathe": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz",
+ "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz",
+ "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^2.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz",
+ "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "diff-sequences": "^29.6.3",
+ "estree-walker": "^3.0.3",
+ "loupe": "^2.3.7",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+ "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
+ "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.3",
+ "pkg-types": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/mlly/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+ "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/pkg-types/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
+ "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinypool": {
+ "version": "0.8.4",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
+ "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
+ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz",
+ "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.4",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
+ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "1.6.1",
+ "@vitest/runner": "1.6.1",
+ "@vitest/snapshot": "1.6.1",
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "acorn-walk": "^8.3.2",
+ "chai": "^4.3.10",
+ "debug": "^4.3.4",
+ "execa": "^8.0.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "tinybench": "^2.5.1",
+ "tinypool": "^0.8.3",
+ "vite": "^5.0.0",
+ "vite-node": "1.6.1",
+ "why-is-node-running": "^2.2.2"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "1.6.1",
+ "@vitest/ui": "1.6.1",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/bindings/node/package.json b/bindings/node/package.json
new file mode 100644
index 0000000..9df96b4
--- /dev/null
+++ b/bindings/node/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@folio/folio",
+ "version": "0.1.0",
+ "description": "Folio: Rust-native PDF conversion, embeddable in Node.",
+ "main": "index.js",
+ "types": "index.d.ts",
+ "license": "MIT",
+ "engines": { "node": ">= 18" },
+ "napi": {
+ "name": "folio-node",
+ "triples": {
+ "defaults": true,
+ "additional": ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
+ }
+ },
+ "scripts": {
+ "build": "napi build --platform --release --cargo-cwd ../../crates/js --cargo-name folio_js --js _native.js --dts _native.d.ts",
+ "build:debug": "napi build --platform --cargo-cwd ../../crates/js --cargo-name folio_js --js _native.js --dts _native.d.ts",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "@napi-rs/cli": "^2.18.0",
+ "@types/node": "^20.0.0",
+ "vitest": "^1.6.0"
+ }
+}
diff --git a/bindings/node/tests/e2e.test.mjs b/bindings/node/tests/e2e.test.mjs
new file mode 100644
index 0000000..2bcd466
--- /dev/null
+++ b/bindings/node/tests/e2e.test.mjs
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import { Folio } from '../index.js';
+
+const E2E = process.env.FOLIO_E2E === '1';
+const here = dirname(fileURLToPath(import.meta.url));
+const fixture = resolve(here, '../../fixtures/hello.html');
+
+describe.skipIf(!E2E)('e2e', () => {
+ it('htmlToPdf', async () => {
+ const f = await Folio.create({ engines: ['chromium'] });
+ try {
+ const html = await readFile(fixture, 'utf8');
+ const pdf = await f.htmlToPdf(html);
+ expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
+ } finally { await f.close(); }
+ }, 120_000);
+
+ it('urlToPdf', async () => {
+ const f = await Folio.create({ engines: ['chromium'] });
+ try {
+ const pdf = await f.urlToPdf('about:blank');
+ expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
+ } finally { await f.close(); }
+ }, 120_000);
+
+ it('markdownToPdf', async () => {
+ const f = await Folio.create({ engines: ['chromium'] });
+ try {
+ const pdf = await f.markdownToPdf('# hello');
+ expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
+ } finally { await f.close(); }
+ }, 120_000);
+});
diff --git a/bindings/node/tests/smoke.test.mjs b/bindings/node/tests/smoke.test.mjs
new file mode 100644
index 0000000..677426a
--- /dev/null
+++ b/bindings/node/tests/smoke.test.mjs
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+import {
+ Folio,
+ FolioError,
+ ChromeNotFoundError,
+ ChromeFetchError,
+ ChromiumError,
+ OfficeError,
+ EngineDisabledError,
+ TimeoutError,
+ ValidationError,
+} from '../index.js';
+
+describe('module exports', () => {
+ it('exposes Folio class with create static method', () => {
+ expect(typeof Folio.create).toBe('function');
+ });
+
+ it('exposes Folio instance methods on prototype', () => {
+ for (const m of ['htmlToPdf', 'urlToPdf', 'markdownToPdf', 'officeToPdf', 'close']) {
+ expect(typeof Folio.prototype[m]).toBe('function');
+ }
+ });
+
+ it('error subclasses extend FolioError', () => {
+ expect(new ChromeNotFoundError('x')).toBeInstanceOf(FolioError);
+ expect(new ChromeFetchError('x')).toBeInstanceOf(FolioError);
+ expect(new ChromiumError('x')).toBeInstanceOf(FolioError);
+ expect(new OfficeError('x')).toBeInstanceOf(FolioError);
+ expect(new EngineDisabledError('x')).toBeInstanceOf(FolioError);
+ expect(new TimeoutError('x')).toBeInstanceOf(FolioError);
+ expect(new ValidationError('x')).toBeInstanceOf(FolioError);
+ });
+
+ it('FolioError extends Error', () => {
+ expect(new FolioError('x')).toBeInstanceOf(Error);
+ });
+});
diff --git a/bindings/python/.gitignore b/bindings/python/.gitignore
new file mode 100644
index 0000000..f1e48b8
--- /dev/null
+++ b/bindings/python/.gitignore
@@ -0,0 +1,6 @@
+.venv/
+__pycache__/
+*.so
+*.pyd
+dist/
+*.egg-info/
diff --git a/bindings/python/README.md b/bindings/python/README.md
new file mode 100644
index 0000000..9add61f
--- /dev/null
+++ b/bindings/python/README.md
@@ -0,0 +1,30 @@
+# folio (Python)
+
+Rust-native PDF conversion, embeddable. See spec at
+`docs/superpowers/specs/2026-05-01-bindings-design.md`.
+
+## Install
+
+ pip install folio
+
+## Quick start
+
+ from folio import Folio
+ with Folio() as f:
+ pdf = f.html_to_pdf("hi
")
+ open("out.pdf", "wb").write(pdf)
+
+## Async
+
+ import asyncio
+ from folio import AsyncFolio
+
+ async def main():
+ f = await AsyncFolio.create()
+ try:
+ pdf = await f.html_to_pdf("hi
")
+ finally:
+ await f.close()
+ return pdf
+
+ asyncio.run(main())
diff --git a/bindings/python/folio/__init__.py b/bindings/python/folio/__init__.py
new file mode 100644
index 0000000..f0e02a7
--- /dev/null
+++ b/bindings/python/folio/__init__.py
@@ -0,0 +1,26 @@
+"""Folio β Rust-native PDF conversion."""
+from ._native import (
+ Folio,
+ AsyncFolio,
+ FolioError,
+ ChromeNotFoundError,
+ ChromeFetchError,
+ ChromiumError,
+ OfficeError,
+ EngineDisabledError,
+ TimeoutError,
+ ValidationError,
+)
+
+__all__ = [
+ "Folio",
+ "AsyncFolio",
+ "FolioError",
+ "ChromeNotFoundError",
+ "ChromeFetchError",
+ "ChromiumError",
+ "OfficeError",
+ "EngineDisabledError",
+ "TimeoutError",
+ "ValidationError",
+]
diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml
new file mode 100644
index 0000000..3209eff
--- /dev/null
+++ b/bindings/python/pyproject.toml
@@ -0,0 +1,27 @@
+[build-system]
+requires = ["maturin>=1.7,<2.0"]
+build-backend = "maturin"
+
+[project]
+name = "folio"
+version = "0.1.0"
+description = "Folio: Rust-native PDF conversion, embeddable in Python."
+readme = "README.md"
+license = { text = "MIT" }
+requires-python = ">=3.8"
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Rust",
+ "License :: OSI Approved :: MIT License",
+]
+
+[project.urls]
+Homepage = "https://github.com/__deesh_reddy__/folio"
+Repository = "https://github.com/__deesh_reddy__/folio"
+
+[tool.maturin]
+manifest-path = "../../crates/py/Cargo.toml"
+module-name = "folio._native"
+features = ["pyo3/extension-module"]
+python-source = "."
+strip = true
diff --git a/bindings/python/tests/test_e2e.py b/bindings/python/tests/test_e2e.py
new file mode 100644
index 0000000..730700f
--- /dev/null
+++ b/bindings/python/tests/test_e2e.py
@@ -0,0 +1,35 @@
+import os, pathlib, pytest
+import folio
+
+E2E = os.environ.get("FOLIO_E2E") == "1"
+pytestmark = pytest.mark.skipif(not E2E, reason="FOLIO_E2E not set")
+
+FIXTURE = pathlib.Path(__file__).resolve().parents[2] / "fixtures" / "hello.html"
+
+def test_html_to_pdf_sync():
+ with folio.Folio(engines=["chromium"]) as f:
+ pdf = f.html_to_pdf(FIXTURE.read_text())
+ assert pdf[:4] == b"%PDF"
+
+def test_url_to_pdf_sync():
+ with folio.Folio(engines=["chromium"]) as f:
+ pdf = f.url_to_pdf("about:blank")
+ assert pdf[:4] == b"%PDF"
+
+def test_markdown_to_pdf_sync():
+ with folio.Folio(engines=["chromium"]) as f:
+ pdf = f.markdown_to_pdf("# hello\n\nfolio e2e")
+ assert pdf[:4] == b"%PDF"
+
+import asyncio
+
+def test_html_to_pdf_async():
+ async def run():
+ f = await folio.AsyncFolio.create(engines=["chromium"])
+ try:
+ pdf = await f.html_to_pdf(FIXTURE.read_text())
+ finally:
+ await f.close()
+ return pdf
+ pdf = asyncio.run(run())
+ assert pdf[:4] == b"%PDF"
diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py
new file mode 100644
index 0000000..1882fe3
--- /dev/null
+++ b/bindings/python/tests/test_smoke.py
@@ -0,0 +1,38 @@
+import folio
+
+def test_module_exports():
+ assert hasattr(folio, "Folio")
+ assert hasattr(folio, "AsyncFolio")
+ assert issubclass(folio.ChromeNotFoundError, folio.FolioError)
+ assert issubclass(folio.ChromiumError, folio.FolioError)
+ assert issubclass(folio.OfficeError, folio.FolioError)
+ assert issubclass(folio.ValidationError, folio.FolioError)
+
+def test_validation_error_class_exists():
+ assert folio.ValidationError is not None
+ assert issubclass(folio.ValidationError, folio.FolioError)
+
+def test_folio_class_methods():
+ # Don't instantiate (would launch Chrome). Just check the class exists.
+ assert hasattr(folio.Folio, "html_to_pdf")
+ assert hasattr(folio.Folio, "url_to_pdf")
+ assert hasattr(folio.Folio, "markdown_to_pdf")
+ assert hasattr(folio.Folio, "office_to_pdf")
+ assert hasattr(folio.Folio, "close")
+ assert hasattr(folio.Folio, "__enter__")
+ assert hasattr(folio.Folio, "__exit__")
+
+def test_async_folio_class_exists():
+ assert hasattr(folio.AsyncFolio, "create")
+ assert hasattr(folio.AsyncFolio, "html_to_pdf")
+ assert hasattr(folio.AsyncFolio, "url_to_pdf")
+ assert hasattr(folio.AsyncFolio, "markdown_to_pdf")
+ assert hasattr(folio.AsyncFolio, "office_to_pdf")
+ assert hasattr(folio.AsyncFolio, "close")
+
+def test_async_folio_create_returns_coroutine():
+ """AsyncFolio.create() must return an awaitable, not eagerly launch."""
+ import folio, inspect
+ # Don't call it (would launch chrome). Just confirm it's a static method
+ # and the signature accepts our args.
+ assert callable(folio.AsyncFolio.create)
diff --git a/comparison.md b/comparison.md
new file mode 100644
index 0000000..d73a1c4
--- /dev/null
+++ b/comparison.md
@@ -0,0 +1,559 @@
+# Folio vs Gotenberg β In-Depth Feature Comparison
+
+> **Snapshot date:** 2026-05-01
+> **Folio commit:** `spec/operator-console` (HEAD: `209a444`)
+> **Gotenberg snapshot:** vendored at `tmp/gotenberg/`
+> **Companion:** `docs/markdown-plus.md` (the new Markdown variation
+> referenced in this comparison's recommendations).
+
+This document is an audit, not a sales sheet. It records what each project
+does *today*, what Folio has chosen not to do (deliberately or not), and
+what is missing relative to Gotenberg parity. It is structured so that any
+single section can be read in isolation by someone deciding whether Folio
+is ready for their workload.
+
+---
+
+## 0. TL;DR
+
+| Axis | Folio | Gotenberg | Verdict |
+|-----------------------------------------|------------------------------------|------------------------------------|------------------------|
+| Core conversions (HTML/URL/MD/Office) | β
Implemented | β
Implemented | **Parity** |
+| Screenshot routes (PNG/JPEG/WebP) | β
Implemented | β
Implemented | **Parity** |
+| PDF ops (merge/split/flatten/rotate/β¦) | β
Implemented (single backend) | β
Implemented (multi backend) | Folio behind on choice |
+| PDF/A & PDF/UA | β
via Ghostscript | β
via LibreOffice + engines | Different paths, OK |
+| Metadata read/write | β
| β
| **Parity** |
+| Bookmarks read/write | β
| β
| **Parity** |
+| Encrypt | β
| β
| **Parity** |
+| Watermark / stamp | β
(watermark) / partial (stamp) | β
both | Folio behind on stamp |
+| Webhook async delivery | π§ Scaffolded, callback TODO | β
Production-grade | **Folio missing** |
+| Batch API | π§ Endpoints + worker, ZIP TODO | β Not offered | Folio ahead (in spec) |
+| Prometheus metrics | β
Rich set | β
Standard set | **Parity** |
+| Structured logs | β
JSON/text + request IDs | β
slog | **Parity** |
+| OpenTelemetry traces | β
OTLP HTTP | β
OTel SDK | **Parity** |
+| Operator console (live UI) | β
Svelte SPA, SSE, charts | β JSON only | **Folio ahead** |
+| Auth (Basic) | β
| β
| **Parity** |
+| TLS | β (rely on reverse proxy) | β
(cert/key flags) | **Folio missing** |
+| SSRF / download allow-deny | partial | β
rich | **Folio behind** |
+| Multi-engine fallback per op | β (lopdf only) | β
qpdf/pdfcpu/pdftk/exiftool | **Folio missing** |
+| Python / Node bindings | β Empty crates | β Not offered | Both miss |
+| CLI (convert/merge/split/β¦) | β
| β Not offered | **Folio ahead** |
+| Library (Rust crate) usage | β
| β Server-only | **Folio ahead** |
+
+**Bottom line.** Folio reaches roughly **85% of Gotenberg's HTTP-surface
+capability** while exceeding it on observability, in-process usage, and
+CLI ergonomics. The remaining 15% β webhook callback delivery, multi-engine
+fallback chains, TLS, fine-grained SSRF controls, advanced Chromium wait
+conditions, the long tail of LibreOffice export filters β is what blocks a
+clean drop-in replacement claim today.
+
+---
+
+## 1. Architecture comparison
+
+### 1.1 Gotenberg
+- **Language:** Go
+- **Framework:** Echo HTTP, modular plugin system
+- **Concurrency model:** Process pools per engine (Chromium / LibreOffice
+ supervised externally), goroutines per request
+- **Rendering:** Each Chromium conversion launches/uses a managed Chrome
+ subprocess; LibreOffice spawns `soffice` per conversion
+- **Deployment shape:** Container-only β the project is explicitly a
+ Docker product
+- **Distribution:** Single binary inside a Debian image with all engines
+ preinstalled
+
+### 1.2 Folio
+- **Language:** Rust
+- **Framework:** axum / tower
+- **Concurrency model:** Tokio tasks, semaphore-bounded; engines wrapped in
+ `SupervisedEngine` with lazy-start / idle-shutdown
+- **Rendering:** Chromium via `chromiumoxide` (CDP) β Folio holds the
+ client; LibreOffice via `soffice` subprocess
+- **Deployment shape:** Container *or* binary *or* Rust library *or* CLI
+- **Distribution:** Multi-target Dockerfile (`folio`, `folio-chromium`,
+ `folio-libreoffice`, `folio-cloudrun`, `folio-lambda`)
+
+### 1.3 What this means in practice
+Folio's choice to live as a *library* is the real architectural divergence
+β it is a strict superset of "PDF microservice", whereas Gotenberg only
+exists as the microservice form. That choice shapes a lot of what
+follows: the supervised-engine wrapper, the operator console, the CLI all
+flow from "we are not married to the HTTP surface."
+
+---
+
+## 2. HTTP API comparison
+
+### 2.1 Endpoint matrix
+
+| Route | Folio | Gotenberg | Notes |
+|---------------------------------------------------|-------|-----------|-------|
+| `POST /forms/chromium/convert/url` | β
| β
| parity |
+| `POST /forms/chromium/convert/html` | β
| β
| parity |
+| `POST /forms/chromium/convert/markdown` | β
| β
| parity, see Β§3.4 |
+| `POST /forms/chromium/screenshot/url` | β
| β
| parity |
+| `POST /forms/chromium/screenshot/html` | β
| β
| parity |
+| `POST /forms/chromium/screenshot/markdown` | β
| β
| parity |
+| `POST /forms/libreoffice/convert` | β
| β
| parity, filter coverage differs (see Β§3.5) |
+| `POST /forms/pdfengines/merge` | β
| β
| parity |
+| `POST /forms/pdfengines/split` | β
| β
| parity |
+| `POST /forms/pdfengines/flatten` | β
| β
| parity |
+| `POST /forms/pdfengines/convert` (PDF/A, PDF/UA) | β
| β
| different backend |
+| `POST /forms/pdfengines/rotate` | β
| β
| parity |
+| `POST /forms/pdfengines/metadata/read` | β
| β
| parity |
+| `POST /forms/pdfengines/metadata/write` | β
| β
| parity |
+| `POST /forms/pdfengines/bookmarks/read` | β
| β
| parity |
+| `POST /forms/pdfengines/bookmarks/write` | β
| β
| parity |
+| `POST /forms/pdfengines/encrypt` | β
| β
| parity |
+| `POST /forms/pdfengines/embed` | β | β
| **Folio missing** β attach files inside PDF |
+| `POST /forms/pdfengines/watermark` | β
| β
| parity |
+| `POST /forms/pdfengines/stamp` | π§ | β
| **Folio partial** β overlay-on-pages variant |
+| `POST /forms/batch/submit` | π§ | β | **Folio ahead in spec** |
+| `GET /forms/batch/{id}/status` | π§ | β | **Folio ahead in spec** |
+| `GET /forms/batch/{id}/download` | π§ | β | **Folio ahead in spec** |
+| `GET /health` | β
| β
| parity |
+| `GET /version` | β
| β | **Folio ahead** (Gotenberg ships version on root) |
+| `GET /prometheus/metrics` | β
| β
| parity |
+| `GET /_/`, `/_/sse`, `/_/metrics.json` | β
| β | **Folio ahead** β operator console |
+| Webhook headers (`Webhook-Url`, etc.) | π§ | β
| callback delivery TODO in Folio |
+
+**Visible gaps in HTTP surface:** `embed`, full `stamp`, complete webhook
+callback delivery, batch ZIP/merge output. Everything else exists.
+
+### 2.2 Request/response shape
+
+Gotenberg insists on multipart/form-data for *every* conversion. Folio
+follows the same convention for all core routes β operators using
+Gotenberg client SDKs (`gotenberg-php`, `gotenberg-js-client`,
+`gotenberg-go-client`) can point at Folio with only a base-URL change for
+the parity routes. This is a deliberate compatibility choice, not an
+accident.
+
+---
+
+## 3. Conversion engines, feature by feature
+
+### 3.1 Chromium β PDF generation
+
+| Feature | Folio | Gotenberg | Notes |
+|-----------------------------------------|-------|-----------|-------|
+| Paper size (named + custom WxH) | β
| β
| parity |
+| Margins (per side, inches) | β
| β
| parity |
+| Landscape | β
| β
| parity |
+| Print background | β
| β
| parity |
+| Omit background (transparency) | β
| β
| parity |
+| Single-page mode | β
| β
| parity |
+| Scale (0.1β2.0) | β
| β
| parity |
+| Page ranges | β
| β
| parity |
+| Custom header/footer HTML w/ tokens | β
| β
| parity |
+| Prefer CSS page size | β
| β
| parity |
+| Tagged PDF / outline | partial | β
| Folio passes flags but limited testing |
+| Cookies (with sameSite) | β
| β
| parity |
+| Extra HTTP headers (scoped) | partial | β
| Folio: flat headers; Gotenberg: regex scope |
+| User-Agent override | β
| β
| parity |
+| Emulated media type | β
| β
| parity |
+| Emulated media features (color-schemeβ¦) | β | β
| **Folio missing** |
+
+### 3.2 Chromium β wait / failure conditions
+
+| Feature | Folio | Gotenberg |
+|--------------------------------------------------|-------|-----------|
+| `waitDelay` (fixed) | β
| β
|
+| `waitForExpression` / custom JS predicate | partial | β
|
+| `waitWindowStatus` | β | β
|
+| `waitForSelector` | β | β
|
+| `skipNetworkIdleEvent` | β | β
|
+| `skipNetworkAlmostIdleEvent` | β | β
|
+| `failOnHttpStatusCodes` | β | β
|
+| `failOnResourceHttpStatusCodes` | β | β
|
+| `ignoreResourceHttpStatusDomains` | β | β
|
+| `failOnResourceLoadingFailed` | β | β
|
+| `failOnConsoleExceptions` | β | β
|
+
+This is the most concrete Chromium feature gap. Spec
+(archived spec) already exists; it just hasn't
+been implemented past the stub. **Recommendation:** prioritise.
+
+### 3.3 Chromium β Screenshots
+
+Both projects support PNG/JPEG/WebP, dimensions, JPEG quality, viewport
+clipping, optimize-for-speed. **Parity.** The only gap is that Folio's
+"capture beyond viewport" code path has fewer integration tests covered
+than Gotenberg's.
+
+### 3.4 Markdown route
+
+Both implementations are minimal. Both produce a wrapped HTML document and
+hand it to Chromium. Differences:
+
+- **Folio:** `pulldown_cmark` with `Options::all()` + a single embedded
+ `markdown.css`. No template injection point.
+- **Gotenberg:** `gomarkdown` + `bluemonday` (sanitised HTML). Requires
+ the user to supply a wrapper HTML file (named `index.html` in the
+ multipart) that pulls the rendered Markdown in via a documented
+ mechanism, so the user can inject CSS/fonts/JS.
+
+Each has a different opinion: Folio is "we own the template, give us
+markdown"; Gotenberg is "you own the template, give us markdown + a
+template."
+
+This comparison's companion document `docs/markdown-plus.md` proposes a
+**third route** that combines both philosophies plus front-matter, math,
+mermaid, syntax highlighting, includes, and named themes. That work is
+designed to ship alongside the existing route, not replace it.
+
+### 3.5 LibreOffice β input formats
+
+Both projects exercise LibreOffice's full ~100-format input matrix (DOC,
+DOCX, ODT, ODS, ODP, XLS, XLSX, PPT, PPTX, RTF, CSV, EPUB, etc.). The
+difference is in **export options**:
+
+| Export option | Folio | Gotenberg |
+|---------------------------------------------|-------|-----------|
+| Landscape | β
| β
|
+| Native page ranges | partial | β
|
+| Single-page mode (Calc/Sheet) | β
| β
|
+| Password-protected input documents | β | β
|
+| Update indexes on conversion | β | β
|
+| Export form fields | β | β
|
+| Export bookmarks | partial | β
|
+| Export notes / placeholders | β | β
|
+| Bookmarks β PDF destinations | β | β
|
+| Image compression (lossless / JPEG quality) | β | β
|
+| Image resolution reduction | β | β
|
+| Viewer preferences (initial view, zoomβ¦) | β | β
|
+| Native LibreOffice watermark | β | β
|
+| PDF/A-1b / 2b / 3b output | β
| β
|
+| PDF/UA output | β
| β
|
+
+Spec (archived spec) lists most of these as
+explicit TODOs.
+
+### 3.6 PDF engine ops
+
+Gotenberg's killer feature here is **per-operation engine selection with
+fallback chains**: `qpdf β pdfcpu β pdftk` for merge, etc. If qpdf
+chokes on a malformed PDF, pdfcpu retries transparently. Folio uses a
+single backend (`lopdf`, pure Rust) for *every* op, which is operationally
+simpler but means a malformed input has no recovery path other than
+"return an error and let the caller deal with it."
+
+This is the largest pure-feature gap. Three options for closing it:
+
+- **(A) Re-implement engine fallback in Rust** by shelling out to qpdf /
+ pdfcpu / pdftk binaries. Cheapest. Loses some of the "no external tools"
+ posture but Folio already shells out to `soffice` and `gs`, so the
+ posture is already mixed.
+- **(B) Stay single-backend and harden lopdf** β file upstream patches for
+ the malformed-input cases that arise. Highest engineering cost, slowest
+ return.
+- **(C) Punt** β say in the README that Folio is "well-formed PDF only"
+ and let users pre-validate. Honest, but caps the addressable workload.
+
+Spec (archived spec) exists and points at (A).
+
+---
+
+## 4. Async delivery β webhooks
+
+Gotenberg's webhook module is mature: middleware POSTs the produced file
+to a user-supplied URL with retry logic, allow/deny lists (literal and
+regex), private/public IP filtering for SSRF, configurable retry windows,
+sync vs async modes.
+
+Folio has the **shape** of this β `Webhook-Url` and friends parse,
+`crates/server/src/webhook/` exists, the worker runs β but the actual
+callback delivery path is marked TODO. Until that lands, an operator
+sending `Webhook-Url` headers will see a 202 and then... nothing.
+
+**Status:** spec (archived spec) is the source of truth; the
+gap is implementation, not design.
+
+---
+
+## 5. Batch API (Folio-only)
+
+Folio has a server-side batch surface that Gotenberg has no equivalent
+for: submit a JSON manifest of N jobs, get back a `batch_id`, poll for
+progress, download a ZIP when done. The endpoints exist; the worker runs;
+ZIP packaging and per-item-failure semantics are TODO.
+
+This is a real differentiator, not just parity-plus. Worth finishing.
+
+---
+
+## 6. Operator console (Folio-only)
+
+This is where Folio is unambiguously ahead.
+
+Gotenberg gives you `/health` (JSON) and `/prometheus/metrics`
+(Prometheus text). That is the entire operability surface. To get any
+actual visibility you wire it into Grafana yourself.
+
+Folio ships a Svelte SPA at `/_/` driven by Server-Sent Events that
+shows, live, in one screen:
+
+- RPS, p95 latency, error %, in-flight count
+- Per-route table (RPS, p50/p95/p99, error %, load %)
+- Engine status (Chromium / LibreOffice up/down + restart count)
+- Concurrency grid (active vs cap, with warn/crit thresholds)
+- Throughput strip (30-min windowed RPS + p95 with SLA overlay)
+- Activity strip (error % + queue depth)
+- Resources (CPU %, memory MB)
+- Active batches (progress + per-item state)
+- Last-20 request log + last-10 error log
+
+The recent commit history (last 30 commits, all dashboard-focused) shows
+this is the team's current focus and it is in active polish.
+
+This shifts the value proposition: Folio is not "Gotenberg in Rust", it
+is "Gotenberg-compatible PDF service that you can run without immediately
+needing a dashboards engineer."
+
+---
+
+## 7. Configuration / CLI flags
+
+Gotenberg has a wide and stable flag surface (api, webhook, pdfengines,
+prometheus, basic auth). Folio's flags cover the same axes but are
+narrower:
+
+| Knob | Folio | Gotenberg |
+|---------------------------------------------|-------|-----------|
+| API port / bind / TLS | port + bind β
, TLS β | β
|
+| Body limit (multipart) | β
| β
|
+| Per-request timeout | β
| β
|
+| Root path (reverse-proxy mount) | β | β
|
+| Correlation ID header | β
| β
|
+| Basic-auth user/pass (env) | β
| β
|
+| Download allow/deny lists | partial | β
|
+| Download deny private/public IPs | partial | β
|
+| Download max retries | β
| β
|
+| Disable downloads entirely | β | β
|
+| Enable debug route | β | β
|
+| Webhook allow/deny + SSRF filters | partial | β
|
+| Webhook retry waits / counts / timeouts | partial | β
|
+| Per-op engine selection (merge/split/β¦) | β | β
|
+| Disable specific PDF engine routes | β | β
|
+| Prometheus namespace / collect interval | partial | β
|
+| Disable route telemetry | β
| β
|
+
+**Recommendation:** the gaps here are individually small; add them one
+by one as `--root-path`, `--api-disable-debug`, `--api-disable-download`,
+and SSRF flags. Spec (archived spec) already exists.
+
+---
+
+## 8. Auth & security posture
+
+| Concern | Folio | Gotenberg |
+|--------------------------------------------|-------|-----------|
+| HTTP Basic Auth | β
| β
|
+| Token / JWT auth | β | β |
+| Per-route authorisation | β | β |
+| TLS in-process | β | β
|
+| `file://` rejected on URL routes | β
| β
|
+| SSRF: private IP block | partial | β
|
+| SSRF: public IP block | β | β
|
+| Download URL allow/deny regex | β | β
|
+| Webhook URL allow/deny regex | partial | β
|
+| Multipart body limit enforcement | β
| β
|
+| Memory-safe core | β
(Rust) | β (Go GC) |
+
+Folio's Rust core is a real security advantage at the parser level;
+Gotenberg's mature SSRF/download/webhook filter stack is a real security
+advantage at the network edge. They are not the same thing and Folio
+should not pretend memory-safety substitutes for the network filters β
+both matter.
+
+---
+
+## 9. Observability
+
+| Surface | Folio | Gotenberg |
+|--------------------------------------|-------|-----------|
+| Structured logs (JSON / text) | β
| β
|
+| Request ID propagation | β
| β
|
+| Prometheus counters/histograms | β
| β
|
+| OpenTelemetry traces | β
(OTLP HTTP) | β
|
+| OpenTelemetry metrics | β
| β
|
+| Live operator UI | β
| β |
+| SSE event stream | β
| β |
+| Per-engine health endpoint detail | β
(per-engine) | β
|
+
+**Parity, with Folio ahead on the live UI.** No gaps to call out here.
+
+---
+
+## 10. Distribution surfaces
+
+| Surface | Folio | Gotenberg |
+|----------------------------------|-------|-----------|
+| HTTP server (Docker) | β
| β
|
+| HTTP server (raw binary) | β
| β (officially Docker-only) |
+| CLI binary (`folio convert β¦`) | β
| β |
+| Rust library (in-process) | β
| β |
+| Python bindings | β (placeholder) | β |
+| Node.js bindings | β (placeholder) | β |
+| Cloud Run image | β
(`folio-cloudrun`) | β |
+| AWS Lambda image | β
(`folio-lambda`) | β |
+| Slim images (Chromium-only / LO-only) | β
| β |
+
+Folio has done real work here that Gotenberg has explicitly said no to
+(Gotenberg's stance is that it is a Docker product; everything else is
+the user's problem). The *empty* Python/Node bindings undercut that
+narrative β the placeholder crates (`crates/py/`, `crates/js/`) imply a
+roadmap commitment that has no actual code. Either ship them or remove
+the placeholders; the worst state is "empty crate that suggests a
+feature."
+
+---
+
+## 11. Test coverage
+
+- **Folio:** ~43 unit tests passing across types, engine, pdfops, routes;
+ ~25 BDD scenarios ported from Gotenberg (runner partially complete);
+ 5 e2e smoke tests; Docker-based PDF/A validation via verapdf.
+ `TEST_STATUS.md` and `TEST_ISSUES.md` are surprisingly honest about
+ what is and isn't passing.
+- **Gotenberg:** mature integration test suite that has been running for
+ years; thousands of cumulative production deployments worth of
+ battle-testing.
+
+The maturity gap is real. Folio's BDD harness is the right move (re-using
+Gotenberg's scenarios is the cheapest path to credibility), it just needs
+to finish.
+
+---
+
+## 12. What Folio did well, with credit
+
+- **Library-first architecture.** Being usable as a Rust crate, a CLI,
+ and a server is a substantial superset of Gotenberg's positioning, and
+ was clearly an early decision rather than a retrofit (the engine crates
+ have no axum imports).
+- **Operator console.** The SSE-driven Svelte dashboard is a genuinely
+ better operator experience than Gotenberg's bare metrics endpoint.
+ This was the right thing to invest in last.
+- **Supervised engines with lazy-start / idle-shutdown.** Memory profile
+ on idle should be substantially better than Gotenberg's eager
+ process-pool model β relevant for serverless deploys (Cloud Run /
+ Lambda images exist for a reason).
+- **Atomic concurrency tracking** (commit `209a444`) over sampled
+ semaphore reads. Small fix, but it's the kind of correctness work that
+ shows the team has actually been driving the dashboard against real
+ load.
+- **Honest test status docs.** `TEST_STATUS.md` and `TEST_ISSUES.md`
+ exist and are not propaganda. Easy to underestimate how rare this is.
+
+## 13. What Folio did not do, deliberately
+
+- **No multi-engine fallback** for PDF ops. Single backend (`lopdf`)
+ keeps the dependency surface small. Defensible until you hit the first
+ malformed-input bug report, at which point the answer becomes "punt or
+ shell out." Decide before users force the decision.
+- **No batch-of-batches / DAG job system.** The batch API is a flat list
+ of jobs, not a workflow. This is the right call for a PDF service β
+ workflow tools belong elsewhere.
+- **No template engine for Markdown.** The basic Markdown route does not
+ let users inject Liquid/Handlebars/etc. The companion proposal
+ (`docs/markdown-plus.md`) preserves this stance: front-matter
+ substitution only, no full templating.
+- **No cross-request server-side state.** Includes resolve from the
+ upload only. This is a security posture, not laziness.
+
+## 14. What Folio did not do, but should
+
+In rough priority order (cheapest-impact-per-LOC first):
+
+1. **Finish webhook callback delivery** ((archived spec)). The
+ Async-202 path is half-built; finishing it unblocks Gotenberg client
+ compatibility.
+2. **Wire advanced Chromium wait conditions** (spec 36): `waitForSelector`,
+ `waitWindowStatus`, `failOn*` family. Each is a single CDP call.
+3. **Finish batch ZIP packaging + per-item failure semantics**
+ (spec 50-batch). The endpoints already exist; finishing them turns a
+ stub into a differentiator.
+4. **Add `embed` + finish `stamp`** routes. Last gaps in the
+ `/forms/pdfengines/*` matrix.
+5. **Implement `--root-path` and SSRF/download filter flags**
+ (spec 39). Small individual changes; collectively close the
+ security/operations gap.
+6. **Decide on multi-engine PDF ops** (spec 38). Either ship qpdf/pdfcpu
+ shellout or commit to "well-formed PDFs only" in the README. Current
+ middle ground is the worst of both.
+7. **Either ship the Python/Node bindings or remove the placeholder
+ crates.** Empty crates are a roadmap lie.
+8. **Fill in LibreOffice export filters** (spec 37). Long tail; do as
+ user demand surfaces, not preemptively.
+9. **Build Markdown+** (`docs/markdown-plus.md`). Net-new feature, not
+ Gotenberg parity, but uses the operator-console + observability
+ investment as a foundation.
+
+## 15. What Folio did not do, and arguably should not
+
+- **TLS in-process.** Use a reverse proxy. Adding TLS to the binary adds
+ cert rotation, OCSP stapling, ALPN β none of which Folio is positioned
+ to do better than nginx/Caddy/envoy. The current "not implemented"
+ status is correct; it should be made *explicit* in the README.
+- **OAuth / JWT / RBAC.** PDF services are not where you want to be doing
+ identity. Stay with Basic Auth + reverse-proxy auth headers; document
+ the pattern.
+- **A workflow / DAG engine on top of batch.** Out of scope. Forever.
+- **A web-UI document editor.** Folio's UI is an operator console, not an
+ end-user product. The line should stay there.
+
+---
+
+## 16. What we did vs what we did not β concise scorecard
+
+### Done
+- Six Chromium routes (HTML/URL/Markdown Γ convert+screenshot)
+- LibreOffice convert route + 100+ input formats
+- All standard PDF ops bar `embed` and full `stamp`
+- PDF/A and PDF/UA via Ghostscript
+- Bookmarks, metadata, encrypt
+- HTTP Basic Auth
+- Prometheus metrics + OpenTelemetry traces + structured logs
+- Operator console (Svelte + SSE) β distinct lead over Gotenberg
+- CLI with convert/merge/split/flatten/rotate/metadata
+- Multi-target Docker images (full / chromium-only / lo-only / cloudrun /
+ lambda)
+- Library usage as a Rust crate
+- BDD test harness (in progress)
+
+### Not done
+- Webhook callback delivery (scaffold only)
+- Batch ZIP output / per-item failure semantics (scaffold only)
+- `embed` route, full `stamp` route
+- Advanced Chromium wait/fail conditions (spec 36)
+- Long tail of LibreOffice export options (spec 37)
+- Multi-engine PDF op fallback (spec 38)
+- Several CLI flags (`--root-path`, full SSRF filters) (spec 39)
+- Python and Node.js bindings (empty placeholder crates)
+- Cookie/header-scope regex filtering on Chromium routes
+- Emulated media features (color-scheme, prefers-reduced-motion)
+- TLS in-process *(deliberately not done; document the choice)*
+
+### Should be added (new)
+- **Markdown+** β see `docs/markdown-plus.md`. Builds on existing
+ Chromium pipeline; uses existing observability stack; ships standalone
+ without blocking on webhook/batch/bindings.
+- **Stage-level histograms** for any multi-stage route (Markdown+ is the
+ obvious first user). Genuine new information, not just parity.
+- **Operator console "Markdown+" panel**, conditionally rendered when
+ traffic exists. Avoids polluting empty deployments.
+
+### Should *not* be added
+- TLS in-process
+- Identity/RBAC inside Folio
+- Workflow/DAG engine on top of batch
+- A document editor
+- A second Markdown route that is "just like the first but with an
+ option" β extension, not duplication
+
+---
+
+*End of comparison. The companion proposal in `docs/markdown-plus.md`
+implements the "should be added (new)" section's first item.*
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 399103d..dc04673 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -9,6 +9,7 @@ description = "Folio core engine: ChromiumEngine, LibreOfficeEngine, PdfOperatio
default = ["chromium", "libreoffice"]
chromium = ["dep:chromiumoxide", "dep:futures-util", "dep:pulldown-cmark", "dep:urlencoding"]
libreoffice = []
+chrome-fetch = ["chromium", "dep:reqwest", "dep:sha2", "dep:zip", "dep:flate2", "dep:tar", "dep:dirs", "dep:walkdir"]
[dependencies]
chromiumoxide = { workspace = true, optional = true }
@@ -30,6 +31,15 @@ image = { workspace = true }
# URL encoding for screenshot data URLs
urlencoding = { version = "2", optional = true }
+# Chrome auto-download (chrome-fetch feature)
+reqwest = { workspace = true, optional = true }
+sha2 = { workspace = true, optional = true }
+zip = { workspace = true, optional = true }
+flate2 = { workspace = true, optional = true }
+tar = { workspace = true, optional = true }
+dirs = { workspace = true, optional = true }
+walkdir = { workspace = true, optional = true }
+
[dev-dependencies]
axum = { workspace = true }
proptest = { workspace = true }
diff --git a/crates/engine/src/chrome_fetch/cache.rs b/crates/engine/src/chrome_fetch/cache.rs
new file mode 100644
index 0000000..6107d6d
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/cache.rs
@@ -0,0 +1,71 @@
+//! Platform cache directory for downloaded Chrome builds.
+
+use std::path::{Path, PathBuf};
+
+/// Default cache root for Folio's downloaded Chrome.
+///
+/// - macOS: `~/Library/Caches/folio/chromium`
+/// - Linux: `$XDG_CACHE_HOME/folio/chromium` (falls back to `~/.cache`)
+/// - Windows: `%LOCALAPPDATA%\folio\chromium`
+///
+/// Override via `FOLIO_CHROME_CACHE` env var; constructor argument wins
+/// over both.
+pub fn cache_dir() -> PathBuf {
+ if let Ok(env) = std::env::var("FOLIO_CHROME_CACHE") {
+ if !env.is_empty() {
+ return PathBuf::from(env);
+ }
+ }
+ let base = dirs::cache_dir().unwrap_or_else(|| PathBuf::from("."));
+ base.join("folio").join("chromium")
+}
+
+/// Returns `Some(path)` if a cached Chrome for `version` exists and the
+/// executable is present.
+pub fn cached_chrome(cache: &Path, version: &str) -> Option {
+ let exe = chrome_exe_path(&cache.join(version));
+ if exe.exists() { Some(exe) } else { None }
+}
+
+/// Path to the Chrome executable inside an extracted Chrome-for-Testing
+/// distribution rooted at `dist`.
+pub(crate) fn chrome_exe_path(dist: &Path) -> PathBuf {
+ #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+ {
+ dist.join("chrome-mac-arm64").join("Google Chrome for Testing.app")
+ .join("Contents/MacOS/Google Chrome for Testing")
+ }
+ #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
+ {
+ dist.join("chrome-mac-x64").join("Google Chrome for Testing.app")
+ .join("Contents/MacOS/Google Chrome for Testing")
+ }
+ #[cfg(target_os = "linux")]
+ {
+ dist.join("chrome-linux64").join("chrome")
+ }
+ #[cfg(target_os = "windows")]
+ {
+ dist.join("chrome-win64").join("chrome.exe")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cache_dir_respects_env_override() {
+ // SAFETY: test mutates env in a non-overlapping way.
+ unsafe { std::env::set_var("FOLIO_CHROME_CACHE", "/tmp/folio-test-cache"); }
+ assert_eq!(cache_dir(), PathBuf::from("/tmp/folio-test-cache"));
+ // SAFETY: justified above.
+ unsafe { std::env::remove_var("FOLIO_CHROME_CACHE"); }
+ }
+
+ #[test]
+ fn cached_chrome_none_when_dir_missing() {
+ let tmp = tempfile::tempdir().unwrap();
+ assert!(cached_chrome(tmp.path(), "999.0.0.0").is_none());
+ }
+}
diff --git a/crates/engine/src/chrome_fetch/detect.rs b/crates/engine/src/chrome_fetch/detect.rs
new file mode 100644
index 0000000..125af26
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/detect.rs
@@ -0,0 +1,109 @@
+//! Detect a usable system Chrome / Chromium executable.
+
+use std::path::PathBuf;
+
+/// Returns the first existing Chrome executable found via env vars,
+/// `$PATH`, and platform-default install paths. Returns `None` if none
+/// of the candidates resolve.
+pub fn detect_system_chrome() -> Option {
+ detect_with(
+ std::env::var("BROWSER_PATH").ok().as_deref(),
+ std::env::var("CHROME_PATH").ok().as_deref(),
+ &|name| which::which(name).ok(),
+ &|p: &std::path::Path| p.exists(),
+ )
+}
+
+pub(crate) fn detect_with(
+ browser_path_env: Option<&str>,
+ chrome_path_env: Option<&str>,
+ path_lookup: &dyn Fn(&str) -> Option,
+ exists: &dyn Fn(&std::path::Path) -> bool,
+) -> Option {
+ for env in [browser_path_env, chrome_path_env].into_iter().flatten() {
+ if !env.is_empty() {
+ let p = PathBuf::from(env);
+ if exists(&p) {
+ return Some(p);
+ }
+ }
+ }
+ for name in ["chromium-browser", "chromium", "google-chrome", "chrome"] {
+ if let Some(p) = path_lookup(name) {
+ return Some(p);
+ }
+ }
+ for candidate in platform_defaults() {
+ let p = PathBuf::from(candidate);
+ if exists(&p) {
+ return Some(p);
+ }
+ }
+ None
+}
+
+#[cfg(target_os = "macos")]
+fn platform_defaults() -> &'static [&'static str] {
+ &[
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ]
+}
+
+#[cfg(target_os = "linux")]
+fn platform_defaults() -> &'static [&'static str] {
+ &[
+ "/usr/bin/google-chrome",
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/snap/bin/chromium",
+ ]
+}
+
+#[cfg(target_os = "windows")]
+fn platform_defaults() -> &'static [&'static str] {
+ &[
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::Path;
+
+ #[test]
+ fn explicit_browser_path_wins_when_exists() {
+ let result = detect_with(
+ Some("/fake/chrome"),
+ None,
+ &|_| None,
+ &|p: &Path| p == Path::new("/fake/chrome"),
+ );
+ assert_eq!(result, Some(PathBuf::from("/fake/chrome")));
+ }
+
+ #[test]
+ fn falls_back_to_path_lookup() {
+ let result = detect_with(
+ None,
+ None,
+ &|name| if name == "chromium" { Some(PathBuf::from("/usr/bin/chromium")) } else { None },
+ &|_| false,
+ );
+ assert_eq!(result, Some(PathBuf::from("/usr/bin/chromium")));
+ }
+
+ #[test]
+ fn returns_none_when_nothing_found() {
+ let result = detect_with(None, None, &|_| None, &|_| false);
+ assert_eq!(result, None);
+ }
+
+ #[test]
+ fn empty_env_var_is_skipped() {
+ let result = detect_with(Some(""), None, &|_| None, &|_| false);
+ assert_eq!(result, None);
+ }
+}
diff --git a/crates/engine/src/chrome_fetch/download.rs b/crates/engine/src/chrome_fetch/download.rs
new file mode 100644
index 0000000..06bd28b
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/download.rs
@@ -0,0 +1,200 @@
+//! Download a pinned Chrome-for-Testing build, verify, extract into the
+//! cache.
+//!
+//! Manifest format:
+//! https://github.com/GoogleChromeLabs/chrome-for-testing
+//!
+//! Per-version endpoint:
+//! `https://googlechromelabs.github.io/chrome-for-testing/.json`
+
+use std::path::{Path, PathBuf};
+
+use serde::Deserialize;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+use tokio::io::AsyncWriteExt;
+
+use super::cache::chrome_exe_path;
+
+/// Errors that can occur when fetching or preparing a Chrome-for-Testing binary.
+#[derive(Debug, Error)]
+pub enum ChromeFetchError {
+ /// System Chrome was not found and `auto_download` was disabled.
+ #[error("system Chrome not found and auto_download disabled")]
+ NotFoundAndDownloadDisabled,
+ /// The current platform is not supported by the Chrome-for-Testing manifest.
+ #[error("unsupported platform: {0}")]
+ UnsupportedPlatform(&'static str),
+ /// Fetching or parsing the per-version manifest failed.
+ #[error("manifest fetch failed: {0}")]
+ Manifest(String),
+ /// The manifest did not contain a download entry for this platform.
+ #[error("no download for platform '{0}' in manifest")]
+ NoPlatformInManifest(&'static str),
+ /// The HTTP download of the Chrome archive failed.
+ #[error("download failed: {0}")]
+ Download(String),
+ /// SHA-256 digest of the downloaded archive did not match the expected value.
+ #[error("checksum mismatch: expected {expected}, got {actual}")]
+ Checksum {
+ /// The expected hex digest from the manifest.
+ expected: String,
+ /// The actual hex digest computed from the downloaded file.
+ actual: String,
+ },
+ /// Extracting the zip archive failed.
+ #[error("extract failed: {0}")]
+ Extract(String),
+ /// An underlying I/O error.
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+}
+
+#[derive(Debug, Deserialize)]
+struct VersionManifest {
+ downloads: Downloads,
+}
+#[derive(Debug, Deserialize)]
+struct Downloads {
+ chrome: Vec,
+}
+#[derive(Debug, Deserialize)]
+struct DownloadEntry {
+ platform: String,
+ url: String,
+}
+
+#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+const PLATFORM: &str = "mac-arm64";
+#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
+const PLATFORM: &str = "mac-x64";
+#[cfg(target_os = "linux")]
+const PLATFORM: &str = "linux64";
+#[cfg(target_os = "windows")]
+const PLATFORM: &str = "win64";
+
+/// Download Chrome `version` into `cache_root//`. Returns the
+/// path to the executable.
+///
+/// Atomicity: download to `cache_root/.partial/`, extract there,
+/// rename to `cache_root//` on success.
+pub async fn download_chrome(cache_root: &Path, version: &str) -> Result {
+ let manifest = fetch_manifest(version).await?;
+ let entry = manifest.downloads.chrome.into_iter()
+ .find(|e| e.platform == PLATFORM)
+ .ok_or(ChromeFetchError::NoPlatformInManifest(PLATFORM))?;
+
+ let dest = cache_root.join(version);
+ if dest.exists() {
+ return Ok(chrome_exe_path(&dest));
+ }
+ tokio::fs::create_dir_all(cache_root).await?;
+ let staging = cache_root.join(format!("{version}.partial"));
+ if staging.exists() {
+ tokio::fs::remove_dir_all(&staging).await?;
+ }
+ tokio::fs::create_dir_all(&staging).await?;
+
+ let archive = staging.join(archive_filename());
+ download_to_file(&entry.url, &archive).await?;
+ extract_archive(&archive, &staging)?;
+ tokio::fs::rename(&staging, &dest).await?;
+ Ok(chrome_exe_path(&dest))
+}
+
+async fn fetch_manifest(version: &str) -> Result {
+ let url = format!(
+ "https://googlechromelabs.github.io/chrome-for-testing/{version}.json"
+ );
+ let resp = reqwest::get(&url).await
+ .map_err(|e| ChromeFetchError::Manifest(e.to_string()))?;
+ if !resp.status().is_success() {
+ return Err(ChromeFetchError::Manifest(format!("HTTP {}", resp.status())));
+ }
+ let text = resp.text().await.map_err(|e| ChromeFetchError::Manifest(e.to_string()))?;
+ serde_json::from_str(&text).map_err(|e| ChromeFetchError::Manifest(e.to_string()))
+}
+
+async fn download_to_file(url: &str, dest: &Path) -> Result<(), ChromeFetchError> {
+ let resp = reqwest::get(url).await
+ .map_err(|e| ChromeFetchError::Download(e.to_string()))?;
+ if !resp.status().is_success() {
+ return Err(ChromeFetchError::Download(format!("HTTP {}", resp.status())));
+ }
+ let bytes = resp.bytes().await
+ .map_err(|e| ChromeFetchError::Download(e.to_string()))?;
+ let mut file = tokio::fs::File::create(dest).await?;
+ file.write_all(&bytes).await?;
+ file.flush().await?;
+ Ok(())
+}
+
+#[allow(dead_code)]
+fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), ChromeFetchError> {
+ let mut hasher = Sha256::new();
+ let mut file = std::fs::File::open(path)?;
+ std::io::copy(&mut file, &mut hasher)?;
+ let actual = hex_lower(&hasher.finalize());
+ if actual != expected_hex.to_ascii_lowercase() {
+ return Err(ChromeFetchError::Checksum { expected: expected_hex.into(), actual });
+ }
+ Ok(())
+}
+
+fn hex_lower(bytes: &[u8]) -> String {
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ s.push_str(&format!("{b:02x}"));
+ }
+ s
+}
+
+fn archive_filename() -> &'static str { "chrome.zip" }
+
+fn extract_archive(archive: &Path, into: &Path) -> Result<(), ChromeFetchError> {
+ let file = std::fs::File::open(archive)?;
+ let mut zip = zip::ZipArchive::new(file)
+ .map_err(|e| ChromeFetchError::Extract(e.to_string()))?;
+ zip.extract(into).map_err(|e| ChromeFetchError::Extract(e.to_string()))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ for entry in walkdir::WalkDir::new(into) {
+ let entry = entry.map_err(|e| ChromeFetchError::Extract(e.to_string()))?;
+ if entry.file_type().is_file()
+ && entry.file_name().to_string_lossy().contains("chrome")
+ {
+ let mut perm = entry.metadata()
+ .map_err(|e| ChromeFetchError::Extract(e.to_string()))?
+ .permissions();
+ perm.set_mode(0o755);
+ let _ = std::fs::set_permissions(entry.path(), perm);
+ }
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn hex_lower_pads_zeros() {
+ assert_eq!(hex_lower(&[0x0a, 0xff, 0x00]), "0aff00");
+ }
+
+ #[test]
+ fn manifest_deserializes() {
+ let json = r#"{
+ "downloads": {
+ "chrome": [
+ {"platform": "linux64", "url": "https://example.com/chrome.zip"},
+ {"platform": "mac-arm64", "url": "https://example.com/mac.zip"}
+ ]
+ }
+ }"#;
+ let m: VersionManifest = serde_json::from_str(json).unwrap();
+ assert_eq!(m.downloads.chrome.len(), 2);
+ }
+}
diff --git a/crates/engine/src/chrome_fetch/mod.rs b/crates/engine/src/chrome_fetch/mod.rs
new file mode 100644
index 0000000..1b86da9
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/mod.rs
@@ -0,0 +1,60 @@
+//! Detect and download Chrome / Chromium for embedded use.
+//!
+//! Bindings (`crates/py`, `crates/js`) call [`ensure_chrome`] which
+//! returns a path to a usable Chrome executable, downloading a pinned
+//! Chrome-for-Testing build into a platform cache directory if no system
+//! Chrome is available.
+//!
+//! See `docs/superpowers/specs/2026-05-01-bindings-design.md` Β§
+//! "Chrome auto-download" for the contract.
+
+#![cfg(feature = "chrome-fetch")]
+
+mod detect;
+mod download;
+mod cache;
+
+pub use detect::detect_system_chrome;
+pub use download::{download_chrome, ChromeFetchError};
+pub use cache::{cache_dir, cached_chrome};
+
+use std::path::PathBuf;
+
+/// Pinned Chrome-for-Testing version. Bumped per Folio release.
+/// Single source of truth: `bindings/CHROME_VERSION` mirrors this string.
+pub const CHROME_VERSION: &str = include_str!("../../../../bindings/CHROME_VERSION");
+
+/// Options controlling [`ensure_chrome`].
+#[derive(Debug, Clone)]
+pub struct EnsureOptions {
+ /// Explicit path to a Chrome executable; skips all detection if set.
+ pub explicit: Option,
+ /// Override the platform cache directory used for downloaded binaries.
+ pub cache_dir: Option,
+ /// When `true`, download Chrome automatically if no system Chrome is found.
+ pub auto_download: bool,
+}
+
+impl Default for EnsureOptions {
+ fn default() -> Self {
+ Self { explicit: None, cache_dir: None, auto_download: true }
+ }
+}
+
+/// Returns a path to a usable Chrome.
+pub async fn ensure_chrome(opts: &EnsureOptions) -> Result {
+ if let Some(p) = &opts.explicit {
+ return Ok(p.clone());
+ }
+ if let Some(p) = detect_system_chrome() {
+ return Ok(p);
+ }
+ let cache = opts.cache_dir.clone().unwrap_or_else(cache_dir);
+ if let Some(p) = cached_chrome(&cache, CHROME_VERSION.trim()) {
+ return Ok(p);
+ }
+ if !opts.auto_download {
+ return Err(ChromeFetchError::NotFoundAndDownloadDisabled);
+ }
+ download_chrome(&cache, CHROME_VERSION.trim()).await
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 04364db..33cd8b1 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -16,6 +16,9 @@ pub mod chromium;
#[cfg(feature = "libreoffice")]
pub mod libreoffice;
+#[cfg(feature = "chrome-fetch")]
+pub mod chrome_fetch;
+
pub use bookmarks::{Bookmark, read_bookmarks, write_bookmarks, flatten_bookmarks};
pub use encrypt::{EncryptionAlgorithm, Permissions, encrypt_pdf, decrypt_pdf, is_encrypted, qpdf_available as encrypt_qpdf_available};
pub use pdfa::{PdfAProfile, convert_to_pdfa, ghostscript_available, qpdf_available};
diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml
index e8608d2..e4a0ad3 100644
--- a/crates/js/Cargo.toml
+++ b/crates/js/Cargo.toml
@@ -9,4 +9,19 @@ description = "Folio Node.js bindings (napi-rs)"
name = "folio_js"
crate-type = ["cdylib"]
+[features]
+default = ["chromium", "libreoffice", "chrome-fetch"]
+chromium = ["engine/chromium"]
+libreoffice = ["engine/libreoffice"]
+chrome-fetch = ["engine/chrome-fetch"]
+
[dependencies]
+engine = { workspace = true }
+napi = { workspace = true, features = ["napi8", "tokio_rt", "serde-json"] }
+napi-derive = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
+
+[build-dependencies]
+napi-build = { workspace = true }
diff --git a/crates/js/build.rs b/crates/js/build.rs
new file mode 100644
index 0000000..0f1b010
--- /dev/null
+++ b/crates/js/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ napi_build::setup();
+}
diff --git a/crates/js/src/errors.rs b/crates/js/src/errors.rs
new file mode 100644
index 0000000..97e49ca
--- /dev/null
+++ b/crates/js/src/errors.rs
@@ -0,0 +1,34 @@
+//! Map `engine::EngineError` and `chrome_fetch::ChromeFetchError` to
+//! tagged napi `Error` instances. The JS-side loader (Task 9) inspects
+//! the `[Tag]` prefix on the message and raises a typed JS error subclass.
+
+use engine::EngineError;
+use napi::{Error, Status};
+
+/// Convert an [`EngineError`] to a napi [`Error`] with a tagged message.
+pub fn engine_to_napi(err: EngineError) -> Error {
+ let (status, msg) = match err {
+ EngineError::ChromeNotFound { .. } =>
+ (Status::GenericFailure, format!("[ChromeNotFound] {err}")),
+ EngineError::Timeout(_) =>
+ (Status::GenericFailure, format!("[Timeout] {err}")),
+ EngineError::InvalidOption(_) | EngineError::InvalidPageRange(_) =>
+ (Status::InvalidArg, format!("[Validation] {err}")),
+ EngineError::ChromeLaunch(_) | EngineError::Cdp(_) | EngineError::Navigation { .. } =>
+ (Status::GenericFailure, format!("[Chromium] {err}")),
+ // No specific Office variant; route everything else to FolioError on the JS side.
+ _ => (Status::GenericFailure, err.to_string()),
+ };
+ Error::new(status, msg)
+}
+
+/// Convert a [`ChromeFetchError`] to a napi [`Error`] with a tagged message.
+#[cfg(feature = "chrome-fetch")]
+pub fn fetch_to_napi(err: engine::chrome_fetch::ChromeFetchError) -> Error {
+ use engine::chrome_fetch::ChromeFetchError as E;
+ let prefix = match err {
+ E::NotFoundAndDownloadDisabled => "[ChromeNotFound]",
+ _ => "[ChromeFetch]",
+ };
+ Error::new(Status::GenericFailure, format!("{prefix} {err}"))
+}
diff --git a/crates/js/src/folio.rs b/crates/js/src/folio.rs
new file mode 100644
index 0000000..9d77662
--- /dev/null
+++ b/crates/js/src/folio.rs
@@ -0,0 +1,256 @@
+//! `class Folio` β async Node.js facade over the engine.
+
+use std::sync::Arc;
+
+use napi::bindgen_prelude::*;
+use napi_derive::napi;
+use serde_json::Value as Json;
+
+#[cfg(feature = "chromium")]
+use engine::ChromiumEngine;
+#[cfg(feature = "libreoffice")]
+use engine::LibreOfficeEngine;
+
+use crate::errors::engine_to_napi;
+#[cfg(feature = "chrome-fetch")]
+use crate::errors::fetch_to_napi;
+
+/// Options passed to [`Folio::create`].
+#[napi(object)]
+pub struct CreateOptions {
+ /// Which engines to enable. Defaults to `["chromium", "office"]`.
+ pub engines: Option>,
+ /// Explicit path to a Chrome/Chromium executable.
+ pub chrome_path: Option,
+ /// Automatically download Chrome if no system Chrome is found.
+ pub auto_download_chrome: Option,
+ /// Directory used to cache downloaded Chrome binaries.
+ pub chrome_cache_dir: Option,
+}
+
+/// Async Folio client that wraps the PDF/document engines.
+#[napi]
+pub struct Folio {
+ #[cfg(feature = "chromium")]
+ chromium: Option>,
+ #[cfg(feature = "libreoffice")]
+ libreoffice: Option>,
+}
+
+#[napi]
+impl Folio {
+ /// Create a new Folio instance, launching the requested engines.
+ #[napi(factory)]
+ pub async fn create(opts: Option) -> Result {
+ let opts = opts.unwrap_or(CreateOptions {
+ engines: None,
+ chrome_path: None,
+ auto_download_chrome: None,
+ chrome_cache_dir: None,
+ });
+ let want = opts.engines.unwrap_or_else(|| vec!["chromium".into(), "office".into()]);
+ let want_chromium = want.iter().any(|s| s == "chromium");
+ let want_office = want.iter().any(|s| s == "office" || s == "libreoffice");
+
+ #[cfg(feature = "chromium")]
+ let chromium = if want_chromium {
+ Some(Arc::new(
+ launch_chromium(
+ opts.chrome_path.as_deref(),
+ opts.auto_download_chrome.unwrap_or(true),
+ opts.chrome_cache_dir.as_deref(),
+ )
+ .await?,
+ ))
+ } else {
+ None
+ };
+
+ #[cfg(feature = "libreoffice")]
+ let libreoffice = if want_office {
+ Some(Arc::new(
+ LibreOfficeEngine::discover().await.map_err(engine_to_napi)?,
+ ))
+ } else {
+ None
+ };
+
+ #[cfg(not(feature = "chromium"))]
+ let _ = (
+ want_chromium,
+ opts.chrome_path,
+ opts.auto_download_chrome,
+ opts.chrome_cache_dir,
+ );
+ #[cfg(not(feature = "libreoffice"))]
+ let _ = want_office;
+
+ Ok(Folio {
+ #[cfg(feature = "chromium")]
+ chromium,
+ #[cfg(feature = "libreoffice")]
+ libreoffice,
+ })
+ }
+
+ /// Convert an HTML string to a PDF buffer.
+ #[napi]
+ pub async fn html_to_pdf(&self, html: String, options: Option) -> Result {
+ let opts: engine::PdfOptions = parse_opts(options)?;
+ #[cfg(feature = "chromium")]
+ {
+ let engine = self.chromium.clone().ok_or_else(|| {
+ Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] chromium engine not enabled",
+ )
+ })?;
+ let ctx = engine::RequestContext::default();
+ let bytes = engine
+ .html_to_pdf(&html, None, &opts, &ctx)
+ .await
+ .map_err(engine_to_napi)?;
+ Ok(bytes.into())
+ }
+ #[cfg(not(feature = "chromium"))]
+ {
+ let _ = (html, opts);
+ Err(Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] chromium feature not compiled in",
+ ))
+ }
+ }
+
+ /// Convert a URL to a PDF buffer.
+ #[napi]
+ pub async fn url_to_pdf(&self, url: String, options: Option) -> Result {
+ let opts: engine::PdfOptions = parse_opts(options)?;
+ #[cfg(feature = "chromium")]
+ {
+ let engine = self.chromium.clone().ok_or_else(|| {
+ Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] chromium engine not enabled",
+ )
+ })?;
+ let ctx = engine::RequestContext::default();
+ let bytes = engine
+ .url_to_pdf(&url, &opts, &ctx)
+ .await
+ .map_err(engine_to_napi)?;
+ Ok(bytes.into())
+ }
+ #[cfg(not(feature = "chromium"))]
+ {
+ let _ = (url, opts);
+ Err(Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] chromium feature not compiled in",
+ ))
+ }
+ }
+
+ /// Convert a Markdown string to a PDF buffer.
+ #[napi]
+ pub async fn markdown_to_pdf(&self, md: String, options: Option) -> Result {
+ let opts: engine::PdfOptions = parse_opts(options)?;
+ #[cfg(feature = "chromium")]
+ {
+ let engine = self.chromium.clone().ok_or_else(|| {
+ Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] chromium engine not enabled",
+ )
+ })?;
+ let ctx = engine::RequestContext::default();
+ let bytes = engine
+ .markdown_to_pdf(&md, &opts, &ctx)
+ .await
+ .map_err(engine_to_napi)?;
+ Ok(bytes.into())
+ }
+ #[cfg(not(feature = "chromium"))]
+ {
+ let _ = (md, opts);
+ Err(Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] chromium feature not compiled in",
+ ))
+ }
+ }
+
+ /// Convert an office document at `path` to a PDF buffer.
+ #[napi]
+ pub async fn office_to_pdf(&self, path: String, options: Option) -> Result {
+ let opts: engine::OfficeOptions = parse_opts(options)?;
+ #[cfg(feature = "libreoffice")]
+ {
+ let engine = self.libreoffice.clone().ok_or_else(|| {
+ Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] libreoffice engine not enabled",
+ )
+ })?;
+ let p = std::path::PathBuf::from(path);
+ let bytes = engine.convert(&p, &opts).await.map_err(engine_to_napi)?;
+ Ok(bytes.into())
+ }
+ #[cfg(not(feature = "libreoffice"))]
+ {
+ let _ = (path, opts);
+ Err(Error::new(
+ Status::GenericFailure,
+ "[EngineDisabled] libreoffice feature not compiled in",
+ ))
+ }
+ }
+
+ /// Shut down the Folio instance and release resources.
+ #[napi]
+ pub async fn close(&self) -> Result<()> {
+ Ok(())
+ }
+}
+
+fn parse_opts(v: Option) -> Result {
+ match v {
+ None => Ok(T::default()),
+ Some(j) => serde_json::from_value(j).map_err(|e| {
+ Error::new(
+ Status::InvalidArg,
+ format!("[Validation] invalid options: {e}"),
+ )
+ }),
+ }
+}
+
+#[cfg(feature = "chromium")]
+async fn launch_chromium(
+ chrome_path: Option<&str>,
+ auto_download: bool,
+ cache_dir: Option<&str>,
+) -> Result {
+ let executable: Option = match chrome_path {
+ Some(p) => Some(p.into()),
+ None => {
+ #[cfg(feature = "chrome-fetch")]
+ {
+ let opts = engine::chrome_fetch::EnsureOptions {
+ explicit: None,
+ cache_dir: cache_dir.map(Into::into),
+ auto_download,
+ };
+ Some(engine::chrome_fetch::ensure_chrome(&opts).await.map_err(fetch_to_napi)?)
+ }
+ #[cfg(not(feature = "chrome-fetch"))]
+ {
+ let _ = (auto_download, cache_dir);
+ None
+ }
+ }
+ };
+ let mut cfg = engine::BrowserConfig::default();
+ cfg.executable = executable;
+ engine::ChromiumEngine::launch_with(cfg).await.map_err(engine_to_napi)
+}
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index b93cf3f..6c0e600 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -1,14 +1,6 @@
-pub fn add(left: u64, right: u64) -> u64 {
- left + right
-}
+//! Folio Node.js bindings β see `bindings/node/README.md`.
-#[cfg(test)]
-mod tests {
- use super::*;
+mod errors;
+mod folio;
- #[test]
- fn it_works() {
- let result = add(2, 2);
- assert_eq!(result, 4);
- }
-}
+pub use folio::{CreateOptions, Folio};
diff --git a/crates/py/Cargo.toml b/crates/py/Cargo.toml
index 5c6909e..cb3f68d 100644
--- a/crates/py/Cargo.toml
+++ b/crates/py/Cargo.toml
@@ -6,7 +6,21 @@ license.workspace = true
description = "Folio Python bindings (PyO3)"
[lib]
-name = "folio_py"
+name = "_native"
crate-type = ["cdylib"]
+[features]
+default = ["chromium", "libreoffice", "chrome-fetch"]
+chromium = ["engine/chromium"]
+libreoffice = ["engine/libreoffice"]
+chrome-fetch = ["engine/chrome-fetch"]
+
[dependencies]
+engine = { workspace = true }
+pyo3 = { workspace = true, features = ["extension-module", "abi3-py38"] }
+pyo3-async-runtimes = { version = "0.22", features = ["tokio-runtime"] }
+tokio = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+parking_lot = "0.12"
diff --git a/crates/py/src/errors.rs b/crates/py/src/errors.rs
new file mode 100644
index 0000000..b5204e3
--- /dev/null
+++ b/crates/py/src/errors.rs
@@ -0,0 +1,54 @@
+//! Map `engine::EngineError` into a Python exception hierarchy.
+
+use engine::EngineError;
+use pyo3::create_exception;
+use pyo3::exceptions::PyException;
+use pyo3::prelude::*;
+
+create_exception!(_native, FolioError, PyException);
+create_exception!(_native, ChromeNotFoundError, FolioError);
+create_exception!(_native, ChromeFetchError, FolioError);
+create_exception!(_native, ChromiumError, FolioError);
+create_exception!(_native, OfficeError, FolioError);
+create_exception!(_native, EngineDisabledError, FolioError);
+create_exception!(_native, TimeoutError, FolioError);
+create_exception!(_native, ValidationError, FolioError);
+
+pub fn engine_to_py(err: EngineError) -> PyErr {
+ match err {
+ EngineError::ChromeNotFound { .. } => ChromeNotFoundError::new_err(err.to_string()),
+ EngineError::Timeout(_) => TimeoutError::new_err(err.to_string()),
+ EngineError::InvalidOption(_) | EngineError::InvalidPageRange(_) => {
+ ValidationError::new_err(err.to_string())
+ }
+ EngineError::ChromeLaunch(_) | EngineError::Cdp(_) | EngineError::Navigation { .. } => {
+ ChromiumError::new_err(err.to_string())
+ }
+ // No specific Office variant in EngineError; office failures surface as
+ // Internal / Io / Pdf depending on what failed. Route everything else
+ // to the generic FolioError. If finer routing matters later, the engine
+ // can grow an Office variant.
+ _ => FolioError::new_err(err.to_string()),
+ }
+}
+
+#[cfg(feature = "chrome-fetch")]
+pub fn fetch_to_py(err: engine::chrome_fetch::ChromeFetchError) -> PyErr {
+ use engine::chrome_fetch::ChromeFetchError as E;
+ match err {
+ E::NotFoundAndDownloadDisabled => ChromeNotFoundError::new_err(err.to_string()),
+ _ => ChromeFetchError::new_err(err.to_string()),
+ }
+}
+
+pub fn register(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
+ m.add("FolioError", py.get_type_bound::())?;
+ m.add("ChromeNotFoundError", py.get_type_bound::())?;
+ m.add("ChromeFetchError", py.get_type_bound::())?;
+ m.add("ChromiumError", py.get_type_bound::())?;
+ m.add("OfficeError", py.get_type_bound::())?;
+ m.add("EngineDisabledError", py.get_type_bound::())?;
+ m.add("TimeoutError", py.get_type_bound::())?;
+ m.add("ValidationError", py.get_type_bound::())?;
+ Ok(())
+}
diff --git a/crates/py/src/folio_async.rs b/crates/py/src/folio_async.rs
new file mode 100644
index 0000000..bcdf6c2
--- /dev/null
+++ b/crates/py/src/folio_async.rs
@@ -0,0 +1,218 @@
+//! `class AsyncFolio` β async facade returning Python awaitables.
+//! Engine futures are bridged to the caller's running event loop via
+//! `pyo3_async_runtimes::tokio::future_into_py`.
+
+use std::sync::Arc;
+
+use pyo3::prelude::*;
+use pyo3::types::{PyBytes, PyDict};
+use pyo3_async_runtimes::tokio::future_into_py;
+
+#[cfg(feature = "chromium")]
+use engine::ChromiumEngine;
+#[cfg(feature = "libreoffice")]
+use engine::LibreOfficeEngine;
+
+use crate::errors::{engine_to_py, EngineDisabledError};
+use crate::types::from_py;
+
+#[pyclass(name = "AsyncFolio", module = "folio")]
+pub struct AsyncFolio {
+ #[cfg(feature = "chromium")]
+ chromium: Option>,
+ #[cfg(feature = "libreoffice")]
+ libreoffice: Option>,
+}
+
+#[pymethods]
+impl AsyncFolio {
+ #[staticmethod]
+ #[pyo3(signature = (engines = None, chrome_path = None, auto_download_chrome = true, chrome_cache_dir = None))]
+ fn create<'py>(
+ py: Python<'py>,
+ engines: Option>,
+ chrome_path: Option,
+ auto_download_chrome: bool,
+ chrome_cache_dir: Option,
+ ) -> PyResult> {
+ let want = engines.unwrap_or_else(|| vec!["chromium".into(), "office".into()]);
+ let want_chromium = want.iter().any(|s| s == "chromium");
+ let want_office = want.iter().any(|s| s == "office" || s == "libreoffice");
+ future_into_py(py, async move {
+ #[cfg(feature = "chromium")]
+ let chromium = if want_chromium {
+ Some(Arc::new(
+ crate::launch::launch_chromium(
+ chrome_path.as_deref(),
+ auto_download_chrome,
+ chrome_cache_dir.as_deref(),
+ )
+ .await?,
+ ))
+ } else {
+ None
+ };
+ #[cfg(feature = "libreoffice")]
+ let libreoffice = if want_office {
+ Some(Arc::new(crate::launch::launch_libreoffice().await?))
+ } else {
+ None
+ };
+ #[cfg(not(feature = "chromium"))]
+ let _ = (want_chromium, chrome_path, auto_download_chrome, chrome_cache_dir);
+ #[cfg(not(feature = "libreoffice"))]
+ let _ = want_office;
+ Python::with_gil(|py| {
+ Ok::(
+ Py::new(
+ py,
+ AsyncFolio {
+ #[cfg(feature = "chromium")]
+ chromium,
+ #[cfg(feature = "libreoffice")]
+ libreoffice,
+ },
+ )?
+ .into_any()
+ .into(),
+ )
+ })
+ })
+ }
+
+ fn html_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ html: String,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::PdfOptions = from_py(options)?;
+ #[cfg(feature = "chromium")]
+ {
+ let engine = self
+ .chromium
+ .clone()
+ .ok_or_else(|| EngineDisabledError::new_err("chromium engine not enabled"))?;
+ future_into_py(py, async move {
+ let ctx = engine::RequestContext::default();
+ let bytes = engine
+ .html_to_pdf(&html, None, &opts, &ctx)
+ .await
+ .map_err(engine_to_py)?;
+ Python::with_gil(|py| Ok::(PyBytes::new_bound(py, &bytes).into()))
+ })
+ }
+ #[cfg(not(feature = "chromium"))]
+ {
+ let _ = (html, opts);
+ Err(EngineDisabledError::new_err("chromium feature not compiled in"))
+ }
+ }
+
+ fn url_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ url: String,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::PdfOptions = from_py(options)?;
+ #[cfg(feature = "chromium")]
+ {
+ let engine = self
+ .chromium
+ .clone()
+ .ok_or_else(|| EngineDisabledError::new_err("chromium engine not enabled"))?;
+ future_into_py(py, async move {
+ let ctx = engine::RequestContext::default();
+ let bytes = engine
+ .url_to_pdf(&url, &opts, &ctx)
+ .await
+ .map_err(engine_to_py)?;
+ Python::with_gil(|py| Ok::(PyBytes::new_bound(py, &bytes).into()))
+ })
+ }
+ #[cfg(not(feature = "chromium"))]
+ {
+ let _ = (url, opts);
+ Err(EngineDisabledError::new_err("chromium feature not compiled in"))
+ }
+ }
+
+ fn markdown_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ md: String,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::PdfOptions = from_py(options)?;
+ #[cfg(feature = "chromium")]
+ {
+ let engine = self
+ .chromium
+ .clone()
+ .ok_or_else(|| EngineDisabledError::new_err("chromium engine not enabled"))?;
+ future_into_py(py, async move {
+ let ctx = engine::RequestContext::default();
+ let bytes = engine
+ .markdown_to_pdf(&md, &opts, &ctx)
+ .await
+ .map_err(engine_to_py)?;
+ Python::with_gil(|py| Ok::(PyBytes::new_bound(py, &bytes).into()))
+ })
+ }
+ #[cfg(not(feature = "chromium"))]
+ {
+ let _ = (md, opts);
+ Err(EngineDisabledError::new_err("chromium feature not compiled in"))
+ }
+ }
+
+ fn office_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ path: String,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::OfficeOptions = from_py(options)?;
+ #[cfg(feature = "libreoffice")]
+ {
+ let engine = self
+ .libreoffice
+ .clone()
+ .ok_or_else(|| EngineDisabledError::new_err("libreoffice engine not enabled"))?;
+ future_into_py(py, async move {
+ let p = std::path::PathBuf::from(path);
+ let bytes = engine.convert(&p, &opts).await.map_err(engine_to_py)?;
+ Python::with_gil(|py| Ok::(PyBytes::new_bound(py, &bytes).into()))
+ })
+ }
+ #[cfg(not(feature = "libreoffice"))]
+ {
+ let _ = (path, opts);
+ Err(EngineDisabledError::new_err("libreoffice feature not compiled in"))
+ }
+ }
+
+ fn close<'py>(&self, py: Python<'py>) -> PyResult> {
+ future_into_py(py, async move {
+ Python::with_gil(|py| Ok::(py.None()))
+ })
+ }
+
+ fn __aenter__<'py>(slf: Py, py: Python<'py>) -> PyResult> {
+ // Return an awaitable that resolves to self.
+ future_into_py(py, async move {
+ Python::with_gil(|py| Ok::(slf.into_any().into_py(py)))
+ })
+ }
+
+ fn __aexit__<'py>(
+ &self,
+ py: Python<'py>,
+ _t: PyObject,
+ _v: PyObject,
+ _tb: PyObject,
+ ) -> PyResult> {
+ self.close(py)
+ }
+}
diff --git a/crates/py/src/folio_sync.rs b/crates/py/src/folio_sync.rs
new file mode 100644
index 0000000..b5ba9be
--- /dev/null
+++ b/crates/py/src/folio_sync.rs
@@ -0,0 +1,214 @@
+//! `class Folio` β sync facade over the engine using a shared tokio runtime.
+
+use std::sync::Arc;
+
+use pyo3::prelude::*;
+use pyo3::types::{PyBytes, PyDict};
+
+#[cfg(feature = "chromium")]
+use engine::ChromiumEngine;
+#[cfg(feature = "libreoffice")]
+use engine::LibreOfficeEngine;
+
+use crate::errors::{engine_to_py, EngineDisabledError};
+use crate::runtime::runtime;
+use crate::types::from_py;
+
+pub(crate) struct State {
+ #[cfg(feature = "chromium")]
+ pub chromium: Option>,
+ #[cfg(feature = "libreoffice")]
+ pub libreoffice: Option>,
+ pub closed: bool,
+}
+
+#[pyclass(name = "Folio", module = "folio")]
+pub struct Folio {
+ pub(crate) inner: parking_lot::Mutex,
+}
+
+#[pymethods]
+impl Folio {
+ #[new]
+ #[pyo3(signature = (engines = None, chrome_path = None, auto_download_chrome = true, chrome_cache_dir = None))]
+ fn new(
+ py: Python<'_>,
+ engines: Option>,
+ chrome_path: Option,
+ auto_download_chrome: bool,
+ chrome_cache_dir: Option,
+ ) -> PyResult {
+ let want = engines.unwrap_or_else(|| vec!["chromium".into(), "office".into()]);
+ let want_chromium = want.iter().any(|s| s == "chromium");
+ let want_office = want.iter().any(|s| s == "office" || s == "libreoffice");
+
+ py.allow_threads(|| -> PyResult {
+ runtime().block_on(async move {
+ #[cfg(feature = "chromium")]
+ let chromium = if want_chromium {
+ Some(Arc::new(
+ crate::launch::launch_chromium(
+ chrome_path.as_deref(),
+ auto_download_chrome,
+ chrome_cache_dir.as_deref(),
+ )
+ .await?,
+ ))
+ } else {
+ None
+ };
+
+ #[cfg(feature = "libreoffice")]
+ let libreoffice = if want_office {
+ Some(Arc::new(crate::launch::launch_libreoffice().await?))
+ } else {
+ None
+ };
+
+ #[cfg(not(feature = "chromium"))]
+ let _ = (want_chromium, chrome_path, auto_download_chrome, chrome_cache_dir);
+ #[cfg(not(feature = "libreoffice"))]
+ let _ = want_office;
+
+ Ok(Folio {
+ inner: parking_lot::Mutex::new(State {
+ #[cfg(feature = "chromium")]
+ chromium,
+ #[cfg(feature = "libreoffice")]
+ libreoffice,
+ closed: false,
+ }),
+ })
+ })
+ })
+ }
+
+ fn html_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ html: &str,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::PdfOptions = from_py(options)?;
+ let engine = self.chromium_or_err()?;
+ let html = html.to_string();
+ let ctx = engine::RequestContext::default();
+ let bytes = py
+ .allow_threads(|| {
+ runtime().block_on(async move {
+ engine.html_to_pdf(&html, None, &opts, &ctx).await
+ })
+ })
+ .map_err(engine_to_py)?;
+ Ok(PyBytes::new_bound(py, &bytes))
+ }
+
+ fn url_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ url: &str,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::PdfOptions = from_py(options)?;
+ let engine = self.chromium_or_err()?;
+ let url = url.to_string();
+ let ctx = engine::RequestContext::default();
+ let bytes = py
+ .allow_threads(|| {
+ runtime().block_on(async move { engine.url_to_pdf(&url, &opts, &ctx).await })
+ })
+ .map_err(engine_to_py)?;
+ Ok(PyBytes::new_bound(py, &bytes))
+ }
+
+ fn markdown_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ md: &str,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::PdfOptions = from_py(options)?;
+ let engine = self.chromium_or_err()?;
+ let md = md.to_string();
+ let ctx = engine::RequestContext::default();
+ let bytes = py
+ .allow_threads(|| {
+ runtime()
+ .block_on(async move { engine.markdown_to_pdf(&md, &opts, &ctx).await })
+ })
+ .map_err(engine_to_py)?;
+ Ok(PyBytes::new_bound(py, &bytes))
+ }
+
+ fn office_to_pdf<'py>(
+ &self,
+ py: Python<'py>,
+ path: &str,
+ options: Option<&Bound<'_, PyDict>>,
+ ) -> PyResult> {
+ let opts: engine::OfficeOptions = from_py(options)?;
+ let engine = self.office_or_err()?;
+ let p = std::path::PathBuf::from(path);
+ let bytes = py
+ .allow_threads(|| {
+ runtime().block_on(async move { engine.convert(&p, &opts).await })
+ })
+ .map_err(engine_to_py)?;
+ Ok(PyBytes::new_bound(py, &bytes))
+ }
+
+ fn close(&self, py: Python<'_>) -> PyResult<()> {
+ py.allow_threads(|| {
+ let mut state = self.inner.lock();
+ if state.closed {
+ return Ok(());
+ }
+ state.closed = true;
+ Ok::<(), PyErr>(())
+ })
+ }
+
+ fn __enter__(slf: Py) -> Py {
+ slf
+ }
+
+ fn __exit__(
+ &self,
+ py: Python<'_>,
+ _t: PyObject,
+ _v: PyObject,
+ _tb: PyObject,
+ ) -> PyResult<()> {
+ self.close(py)
+ }
+}
+
+impl Folio {
+ #[cfg(feature = "chromium")]
+ fn chromium_or_err(&self) -> PyResult> {
+ self.inner.lock().chromium.clone().ok_or_else(|| {
+ EngineDisabledError::new_err("chromium engine not enabled for this Folio instance")
+ })
+ }
+
+ #[cfg(not(feature = "chromium"))]
+ fn chromium_or_err(&self) -> PyResult<()> {
+ Err(EngineDisabledError::new_err(
+ "chromium feature not compiled in",
+ ))
+ }
+
+ #[cfg(feature = "libreoffice")]
+ fn office_or_err(&self) -> PyResult> {
+ self.inner.lock().libreoffice.clone().ok_or_else(|| {
+ EngineDisabledError::new_err("libreoffice engine not enabled for this Folio instance")
+ })
+ }
+
+ #[cfg(not(feature = "libreoffice"))]
+ fn office_or_err(&self) -> PyResult<()> {
+ Err(EngineDisabledError::new_err(
+ "libreoffice feature not compiled in",
+ ))
+ }
+}
diff --git a/crates/py/src/launch.rs b/crates/py/src/launch.rs
new file mode 100644
index 0000000..1264f9f
--- /dev/null
+++ b/crates/py/src/launch.rs
@@ -0,0 +1,53 @@
+//! Centralised engine-launch wiring for both Folio and AsyncFolio.
+
+#[cfg(feature = "chromium")]
+use engine::BrowserConfig;
+
+#[cfg(feature = "chromium")]
+pub async fn launch_chromium(
+ chrome_path: Option<&str>,
+ auto_download: bool,
+ cache_dir: Option<&str>,
+) -> Result {
+ use crate::errors::engine_to_py;
+ #[cfg(feature = "chrome-fetch")]
+ use crate::errors::fetch_to_py;
+
+ let executable: Option = match chrome_path {
+ Some(p) => Some(p.into()),
+ None => {
+ #[cfg(feature = "chrome-fetch")]
+ {
+ let opts = engine::chrome_fetch::EnsureOptions {
+ explicit: None,
+ cache_dir: cache_dir.map(Into::into),
+ auto_download,
+ };
+ Some(
+ engine::chrome_fetch::ensure_chrome(&opts)
+ .await
+ .map_err(fetch_to_py)?,
+ )
+ }
+ #[cfg(not(feature = "chrome-fetch"))]
+ {
+ let _ = (auto_download, cache_dir);
+ None
+ }
+ }
+ };
+
+ let mut cfg = BrowserConfig::default();
+ cfg.executable = executable;
+ engine::ChromiumEngine::launch_with(cfg)
+ .await
+ .map_err(engine_to_py)
+}
+
+#[cfg(feature = "libreoffice")]
+pub async fn launch_libreoffice() -> Result {
+ use crate::errors::engine_to_py;
+ engine::LibreOfficeEngine::discover()
+ .await
+ .map_err(engine_to_py)
+}
diff --git a/crates/py/src/lib.rs b/crates/py/src/lib.rs
index b93cf3f..0ffa02d 100644
--- a/crates/py/src/lib.rs
+++ b/crates/py/src/lib.rs
@@ -1,14 +1,24 @@
-pub fn add(left: u64, right: u64) -> u64 {
- left + right
-}
+//! Folio Python bindings β see `bindings/python/README.md`.
+
+mod errors;
+mod folio_async;
+mod folio_sync;
+mod launch;
+mod runtime;
+mod types;
-#[cfg(test)]
-mod tests {
- use super::*;
+use pyo3::prelude::*;
- #[test]
- fn it_works() {
- let result = add(2, 2);
- assert_eq!(result, 4);
- }
+#[pymodule]
+fn _native(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
+ // Initialize the tokio runtime builder that pyo3-async-runtimes will use
+ // to drive futures returned by AsyncFolio methods.
+ // pyo3_async_runtimes::tokio::init takes a Builder (not a built Runtime).
+ let mut builder = tokio::runtime::Builder::new_multi_thread();
+ builder.enable_all().thread_name("folio-py-async");
+ pyo3_async_runtimes::tokio::init(builder);
+ errors::register(py, m)?;
+ m.add_class::()?;
+ m.add_class::()?;
+ Ok(())
}
diff --git a/crates/py/src/runtime.rs b/crates/py/src/runtime.rs
new file mode 100644
index 0000000..29d7c7d
--- /dev/null
+++ b/crates/py/src/runtime.rs
@@ -0,0 +1,17 @@
+//! Shared single tokio runtime used by sync Folio.
+//! AsyncFolio uses pyo3-async-runtimes' bridged runtime instead.
+
+use std::sync::OnceLock;
+use tokio::runtime::Runtime;
+
+static RUNTIME: OnceLock = OnceLock::new();
+
+pub fn runtime() -> &'static Runtime {
+ RUNTIME.get_or_init(|| {
+ tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .thread_name("folio-py")
+ .build()
+ .expect("init folio-py tokio runtime")
+ })
+}
diff --git a/crates/py/src/types.rs b/crates/py/src/types.rs
new file mode 100644
index 0000000..774473a
--- /dev/null
+++ b/crates/py/src/types.rs
@@ -0,0 +1,55 @@
+//! Convert between Python dicts and engine option structs by routing
+//! through `serde_json`. This avoids hand-writing a `FromPyObject` impl
+//! for every engine option, and matches the engine's existing `serde`
+//! contract used by the HTTP server.
+
+use pyo3::prelude::*;
+use pyo3::types::{PyDict, PyString, PyStringMethods as _};
+use serde::de::DeserializeOwned;
+
+pub fn from_py(opts: Option<&Bound<'_, PyDict>>) -> PyResult {
+ let Some(d) = opts else {
+ return Ok(T::default());
+ };
+ let json = pyobject_to_json(d.as_any())?;
+ serde_json::from_value(json).map_err(|e| {
+ super::errors::ValidationError::new_err(format!("invalid options: {e}"))
+ })
+}
+
+fn pyobject_to_json(obj: &Bound<'_, PyAny>) -> PyResult {
+ if obj.is_none() {
+ return Ok(serde_json::Value::Null);
+ }
+ if let Ok(b) = obj.extract::() {
+ return Ok(b.into());
+ }
+ if let Ok(i) = obj.extract::() {
+ return Ok(i.into());
+ }
+ if let Ok(f) = obj.extract::() {
+ return Ok(f.into());
+ }
+ if let Ok(s) = obj.downcast::() {
+ return Ok(s.to_string_lossy().to_string().into());
+ }
+ if let Ok(seq) = obj.downcast::() {
+ let mut out = Vec::with_capacity(seq.len());
+ for item in seq.iter() {
+ out.push(pyobject_to_json(&item)?);
+ }
+ return Ok(serde_json::Value::Array(out));
+ }
+ if let Ok(d) = obj.downcast::() {
+ let mut map = serde_json::Map::new();
+ for (k, v) in d.iter() {
+ let k: String = k.extract()?;
+ map.insert(k, pyobject_to_json(&v)?);
+ }
+ return Ok(serde_json::Value::Object(map));
+ }
+ let type_name = obj.get_type().name()?.to_string_lossy().to_string();
+ Err(super::errors::ValidationError::new_err(format!(
+ "unsupported python type: {type_name}"
+ )))
+}
diff --git a/docs/markdown-plus.md b/docs/markdown-plus.md
new file mode 100644
index 0000000..9456cbf
--- /dev/null
+++ b/docs/markdown-plus.md
@@ -0,0 +1,307 @@
+# Folio Markdown+ β A New MarkdownβPDF Variation
+
+> **Status:** Design proposal. Companion to `comparison.md` at the repo root.
+> **Scope:** Defines a third Markdown rendering route for Folio that sits
+> alongside the existing `/forms/chromium/convert/markdown` (basic) and the
+> Gotenberg-compatible template-based path. Targets document-quality output
+> (reports, dossiers, technical writing) rather than the raw GFM-in-a-box
+> baseline.
+
+---
+
+## 1. Why a new variation?
+
+Today Folio offers a single Markdown pipeline (`crates/engine/src/chromium/markdown.rs`):
+
+- `pulldown_cmark` with `Options::all()` (tables, strikethrough, task lists,
+ footnotes, smart punctuation).
+- Wrapped in a fixed `` shell with a single bundled stylesheet
+ (`markdown.css`).
+- Rendered through Chromium β PDF.
+
+That covers the Gotenberg-equivalent baseline, but it falls short for the
+target users implied by the operator console + observability investment:
+people producing **report-grade PDFs at scale** β incident write-ups,
+generated dossiers, customer-facing one-pagers, weekly digests.
+
+Gaps observed against both the current code and Gotenberg's
+`/forms/chromium/convert/markdown`:
+
+| Need | Current Folio | Gotenberg | Gap |
+|---------------------------------------|---------------|-----------|----------------|
+| YAML / TOML front-matter for metadata | β | β | both miss |
+| Math (KaTeX / MathJax) rendering | β | β | both miss |
+| Mermaid / PlantUML diagrams | β | β | both miss |
+| Syntax-highlighted code | β (CSS only) | β | both miss |
+| Admonitions / callouts | β | β | both miss |
+| Auto table-of-contents | β | β | both miss |
+| Themed templates (named styles) | β | β | both miss |
+| Header/footer driven by front-matter | β | partial | folio behind |
+| Cover page generation | β | β | both miss |
+| Cross-document includes (`@include`) | β | β | both miss |
+| Asset upload + relative paths | partial | β
| folio behind |
+
+The "new variation" β **Markdown+** β targets the bottom half of that table
+in a single coherent route. It is *not* a replacement for the basic route;
+the basic route stays as the cheapest, fastest, GFM-baseline path.
+
+---
+
+## 2. Route design
+
+```
+POST /forms/chromium/convert/markdown-plus
+```
+
+Multipart form-data, same auth/observability stack as every other Chromium
+route. Discovery via `/_/`, Prometheus, OTel traces wired identically.
+
+### 2.1 Form fields
+
+| Field | Type | Required | Purpose |
+|----------------------|----------------|----------|------------------------------------------------------------|
+| `index.md` | file | β
| Entry-point document |
+| `*.md` | file (repeat) | β | Additional documents resolvable via `@include` |
+| `assets/**` | files | β | Images, fonts, custom CSS resolvable by relative path |
+| `theme` | text | β | Named theme: `default`, `report`, `book`, `slide`, `memo` |
+| `stylesheet` | file | β | Override CSS β applied **after** the theme |
+| `math` | text | β | `none` \| `katex` \| `mathjax` (default: `katex` if `$`s) |
+| `diagrams` | text | β | `none` \| `mermaid` \| `auto` (default: `auto`) |
+| `highlight` | text | β | `none` \| `prism` \| `treesitter` (default: `prism`) |
+| `toc` | text | β | `none` \| `auto` \| `front` \| `back` (default: `auto`) |
+| `cover` | text | β | `none` \| `auto` (renders cover from front-matter) |
+| `frontMatterFormat` | text | β | `yaml` \| `toml` (default: detect by fence) |
+| ... (all PDF options from basic route inherited unchanged) |
+
+Anything in the basic route's PDF options block (paper size, margins,
+landscape, header/footer HTML, scale, page ranges, cookies, headers) flows
+through unchanged so Markdown+ does not become a parallel options surface.
+
+### 2.2 Front-matter contract
+
+A document opens with a fenced front-matter block:
+
+```markdown
+---
+title: Q2 Reliability Review
+author: Folio SRE
+date: 2026-04-30
+classification: internal
+toc: true
+theme: report
+header: "{title} β {classification}"
+footer: "Page {pageNumber} of {totalPages}"
+---
+
+# Executive summary
+...
+```
+
+The renderer:
+
+1. Strips and parses the block (`serde_yaml` / `toml`).
+2. Promotes selected keys onto the PDF: `title` β ``, `author` β
+ `dc:creator` metadata, `date` β header substitution, etc.
+3. Substitutes `{title}`, `{author}`, `{date}`, `{pageNumber}`,
+ `{totalPages}`, `{url}`, `{classification}` inside header/footer HTML
+ *before* it reaches Chromium.
+4. Anything in front-matter beats the matching form field β front-matter is
+ the document's voice; form fields are the operator's voice. (Inverse
+ precedence is wrong: it would let an operator silently relabel a
+ classified document.)
+
+### 2.3 Pipeline
+
+```
+markdown bytes
+ β
+ βββ front-matter split (yaml|toml)
+ β
+ βββ @include resolution (recursive, cycle-detected, depth-capped)
+ β
+ βββ pulldown-cmark (Options::all + custom event stream)
+ β β
+ β βββ inline math $...$ β
+ β βββ block math $$...$$ β
+ β βββ ```mermaid β
...
+ β βββ ```lang β highlighted
+ β βββ > [!NOTE]β¦ admonition β