From 79853343e2aaf9eebe9706e6bb58bf7052cd72c4 Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 17 Jul 2026 20:10:43 -0600 Subject: [PATCH 1/9] feat: in-browser WASM playground at /playground - Feature-gate cli (clap+reqwest) behind default-on 'cli' feature; extract pure URL policy + spec parsing into src/spec_source.rs so the library compiles on wasm32-unknown-unknown with --no-default-features - Add wasm/ workspace member wrapping the in-memory pipeline for the browser; output is byte-identical to 'openapi-to-rust generate ' (verified by diff) and includes a complete compilable crate file set - Playground page: paste/URL-fetch/example inputs, Web Worker generation, highlighted tabbed output, downloadable runnable crate as .tar.gz - Deploy via artifact pull, no Vercel secrets: playground-wasm.yml publishes the bundle as a public release asset and bumps website/playground-wasm.lock; the website prebuild fetch script stages the pinned asset when no local build exists (bootstrap release playground-wasm-0.7.0-bootstrap is live) Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 5 + .github/workflows/playground-wasm.yml | 75 +++ .gitignore | 5 + Cargo.lock | 54 +- Cargo.toml | 13 +- scripts/build-playground-wasm.sh | 20 + src/bin/openapi-to-rust.rs | 36 +- src/cli.rs | 238 +------ src/config.rs | 8 +- src/lib.rs | 8 + src/spec_source.rs | 271 ++++++++ tests/cli_workflow_test.rs | 3 +- tests/server_config_test.rs | 4 +- wasm/Cargo.toml | 21 + wasm/src/lib.rs | 180 ++++++ website/package-lock.json | 10 + website/package.json | 3 + website/playground-wasm.lock | 1 + .../examples/multiple_operations.json | 137 ++++ .../playground/examples/path_params.json | 51 ++ .../playground/examples/simple_get.json | 44 ++ website/public/playground/worker.js | 20 + website/scripts/fetch-playground-wasm.mjs | 51 ++ website/src/components/SiteHeader.astro | 1 + website/src/pages/playground.astro | 602 ++++++++++++++++++ 25 files changed, 1568 insertions(+), 293 deletions(-) create mode 100644 .github/workflows/playground-wasm.yml create mode 100755 scripts/build-playground-wasm.sh create mode 100644 src/spec_source.rs create mode 100644 wasm/Cargo.toml create mode 100644 wasm/src/lib.rs create mode 100644 website/playground-wasm.lock create mode 100644 website/public/playground/examples/multiple_operations.json create mode 100644 website/public/playground/examples/path_params.json create mode 100644 website/public/playground/examples/simple_get.json create mode 100644 website/public/playground/worker.js create mode 100644 website/scripts/fetch-playground-wasm.mjs create mode 100644 website/src/pages/playground.astro diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f828e43..a8996d1 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,7 @@ +{"id":"openapi-generator-5gn","title":"Playground page: /playground on Astro site","description":"website/src/pages/playground.astro: paste textarea, URL fetch w/ CORS fallback message, example dropdown, Web Worker generation, tabbed output w/ copy, version footer, in-page generator errors. No any types, static output, lazy wasm.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:44Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:01Z","closed_at":"2026-07-18T02:10:01Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-zkx","title":"wasm/ crate: wasm-bindgen wrapper + build script","description":"cdylib workspace member openapi-to-rust-wasm exporting generate(spec, configToml) and version(); scripts/build-playground-wasm.sh: wasm-pack build --target web + wasm-opt -Oz -\u003e website/public/playground/pkg/ (gitignored).","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:43Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:00Z","closed_at":"2026-07-18T02:10:00Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-0o4","title":"Feature-gate cli/reqwest/clap for wasm32 build","description":"Make reqwest+clap optional behind default-on 'cli' feature; gate src/cli.rs + http_error re-exports; config.rs uses url::Url. Gate: cargo check --target wasm32-unknown-unknown --no-default-features passes, CLI unchanged with defaults.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:42Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:00Z","closed_at":"2026-07-18T02:10:00Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-4u6","title":"Playground epic: in-browser WASM playground on website","description":"Ship /playground on the Astro site: paste/fetch an OpenAPI spec, see generated Rust from the real generator compiled to WASM. Local-first, then live via artifact-pull pipeline (no Vercel token). See conversation goal text.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:39:37Z","created_by":"James Lal","updated_at":"2026-07-18T01:39:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-554","title":"Array items with inline string enum generate Vec\u003cString\u003e instead of named enum (gh#33)","description":"GitHub issue gpu-cli/openapi-to-rust#33: analyze_array_schema maps string array items straight to String, ignoring enum values, while analyze_property_schema_with_context hoists inline string enums into named types. Fix: extract the enum-hoisting logic into a shared helper and use it for array items too.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T14:40:53Z","created_by":"James Lal","updated_at":"2026-07-17T14:52:45Z","started_at":"2026-07-17T14:41:15Z","closed_at":"2026-07-17T14:52:45Z","close_reason":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/34 (fixes gh#33): array items with inline string enums now hoist to named {Parent}Item enums via shared hoist_inline_string_enum helper","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dj7","title":"Distill and harden developer-first homepage","notes":"Rewrote homepage from 1,007 to 603 visible words (40.1% reduction) and 10 to 6 sections. Hero now states exact output and two-command trial; CI proof separates active PR tier from configured scheduled/manual full-corpus tier; real model/client/server artifacts are linked; repeated eyebrow/grid/window-dot patterns removed; spacing and z-index tokens added; copy failure is visible; neutral background replaces decorative cream grid. Impeccable critique trend 30→36→38 with 0 P0/P1. Gates: Astro 0 diagnostics; 9 pages built; html-validate pass; SEO audit pass; detector and layout detector clean. Playwright MCP and isolated critique contexts verified 1440x1000, 768x1024, 390x844: no page/section/H1 overflow, no console errors, copy/menu/FAQ/focus/touch targets pass. Production deployment dpl_9qhn2wtuiYUYuPsSdduALvGUSicu READY and aliased to https://openapi-to-rust.dev; live desktop/mobile Playwright plus canonical/robots/sitemap/security-header/404 checks pass.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:53:05Z","created_by":"James Lal","updated_at":"2026-07-16T05:33:51Z","started_at":"2026-07-16T04:53:11Z","closed_at":"2026-07-16T05:33:51Z","close_reason":"Developer-first homepage meets all stopping criteria, is deployed, visually verified, and scores 38/40 with no P0/P1 findings.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-gl9","title":"Build and deploy SEO-first Astro documentation site","description":"Create an Astro landing page and documentation site for openapi-to-rust, targeting Rust OpenAPI code-generation search intent; include strong technical SEO, accessible responsive design, local QA, and Vercel deployment.","design":"Host the site in a self-contained website directory so Rust crate workflows remain isolated. Favor static HTML, minimal JavaScript, content-rich documentation, honest comparisons, and code-native visuals.","acceptance_criteria":"Astro site builds successfully; landing page targets openapi generator rust and adjacent intent; metadata, canonical URLs, sitemap, robots, structured data, and social cards are present; responsive and accessible UI is browser-tested and critiqued; deployment succeeds on Vercel; all changes are committed and pushed.","notes":"Implemented an SEO-first Astro site under website/ with 8 indexable routes plus a custom 404; canonical origin https://openapi-to-rust.dev; deployed to Vercel project lbl-rd/openapi-to-rust; Git integration root is website; custom domain verified. Validation: remote Vercel build clean, Astro check clean, SEO audit passed for 9 rendered pages, html-validate passed, live HTTP/canonical/sitemap/security-header/404 checks passed. Playwright browser bridge was present but exposed zero browser backends, so visual testing used independent source/rendered-HTML critique and OG image inspection rather than screenshots.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:03:33Z","created_by":"James Lal","updated_at":"2026-07-16T04:34:21Z","started_at":"2026-07-16T04:03:57Z","closed_at":"2026-07-16T04:34:21Z","close_reason":"SEO site implemented, critiqued, validated, deployed, and connected to the custom domain and Git repository.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -12,6 +16,7 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-l7v","title":"Artifact pipeline: playground-wasm.yml + fetch script + lock","description":"GH Action builds wasm on src/wasm changes, uploads release asset (GITHUB_TOKEN only), bumps website/playground-wasm.lock; website prebuild/predev fetch script pulls pinned asset when pkg/ absent. No Vercel token.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:44Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:01Z","closed_at":"2026-07-18T02:10:01Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-26c","title":"Impeccable homepage and developer-copy critique","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:40:48Z","created_by":"James Lal","updated_at":"2026-07-16T04:48:45Z","started_at":"2026-07-16T04:41:10Z","closed_at":"2026-07-16T04:48:45Z","close_reason":"Completed dual-agent Impeccable critique with developer-copy focus; archived 30/40 baseline and prioritized trust, claim, copy, sequencing, and visual-system issues.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-1sb.10","title":"Replace quadratic operation-ID collision scan","description":"SchemaAnalyzer::ingest_path_item_operations canonicalizes and linearly scans every prior operation ID for each new operation. The 16k-operation Microsoft Graph corpus stays CPU-bound in this scan for many minutes. Maintain an analysis-wide canonical-ID set (including disambiguated candidates) so collision detection is O(1) average without changing emitted IDs or alias diagnostics.","acceptance_criteria":"Existing duplicate/case-collision operation-ID tests remain identical; add a large synthetic regression that verifies deterministic IDs without quadratic scanning; the Microsoft Graph generation portion of scripts/spec-compile.sh completes materially faster; full suite passes.","notes":"Replaced the per-operation full BTreeMap canonicalization scan with one analysis-wide HashSet shared across paths and webhooks. Added a 2,003-operation deterministic collision/alias regression (Foo.bar, foo_bar_get, foo_bar_get_2), which completes in 0.05s. Microsoft Graph exposed two additional request-model generation rescans; precomputed request-body roots and reserved Rust names once per types.rs pass without changing output. Microsoft Graph generation now completes in ~30s versus the original run remaining CPU-bound after ~20 minutes. Full scripts/spec-compile.sh result: 54 passed, 0 gen-failed, 0 check-failed, 1 expected Swagger 2.0 skip. Full fmt/check/clippy/docs/tests pass.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T17:03:41Z","created_by":"James Lal","updated_at":"2026-07-15T17:22:43Z","started_at":"2026-07-15T17:10:33Z","closed_at":"2026-07-15T17:22:43Z","close_reason":"Canonical operation IDs and type-generation lookups are indexed once; large regression, Microsoft Graph, full corpus, and all quality gates pass.","labels":["accessibility","adoption","analysis","performance"],"dependencies":[{"issue_id":"openapi-generator-1sb.10","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T11:03:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.10","depends_on_id":"openapi-generator-st8","type":"discovered-from","created_at":"2026-07-15T11:03:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-1sb.6","title":"Improve discovery community scaffolding and public documentation","description":"Rewrite the README opening for a 30-second trial, clarify OpenAPI support and limitations, improve Cargo/crate metadata and docs, add contributor/security/conduct/support/templates, and update repository discovery metadata where authorized.","acceptance_criteria":"README clearly positions typed models, async clients, Axum servers, SSE, and experimental 3.2; required community files and issue/PR templates exist; contributor checks are documented; public API docs/examples improve; GitHub topics and links are updated when supported.","notes":"Public GitHub settings aligned 2026-07-15: description now covers typed Rust models, async HTTP/SSE clients, Axum servers, and OpenAPI 3.0/3.1; homepage points to docs.rs; topics expanded to the 20-topic limit with api-server, axum, json-schema, openapi-31, and rust-codegen; Discussions enabled; private vulnerability reporting enabled and verified.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:58Z","created_by":"James Lal","updated_at":"2026-07-15T19:33:17Z","started_at":"2026-07-15T19:27:49Z","closed_at":"2026-07-15T19:33:17Z","close_reason":"Added and validated the fast trial, docs.rs overview, contributor/support/security/conduct files, structured issue forms and PR template, default Cargo run target, and updated GitHub description/homepage/topics/Discussions/private reporting.","labels":["accessibility","adoption","community","discovery","documentation"],"dependencies":[{"issue_id":"openapi-generator-1sb.6","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:31:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.6","depends_on_id":"openapi-generator-1sb.2","type":"blocks","created_at":"2026-07-15T08:39:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} diff --git a/.github/workflows/playground-wasm.yml b/.github/workflows/playground-wasm.yml new file mode 100644 index 0000000..527104f --- /dev/null +++ b/.github/workflows/playground-wasm.yml @@ -0,0 +1,75 @@ +name: Playground WASM + +# Builds the website playground's WASM bundle whenever the generator changes, +# publishes it as a public release asset, and bumps website/playground-wasm.lock. +# The lock-bump commit triggers Vercel's git auto-deploy, whose build fetches +# the asset via website/scripts/fetch-playground-wasm.mjs. No Vercel secrets. +# +# Self-retrigger is impossible by construction: the lock file lives under +# website/, outside this workflow's path filter. + +on: + push: + branches: [main] + paths: + - "src/**" + - "wasm/**" + - "Cargo.toml" + - "Cargo.lock" + - "scripts/build-playground-wasm.sh" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: playground-wasm + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: wasm-pack + + - name: Build playground bundle + run: ./scripts/build-playground-wasm.sh + + - name: Package and publish release asset + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name == "openapi-to-rust") | .version') + tag="playground-wasm-${version}-${GITHUB_SHA::7}" + tar czf playground-pkg.tar.gz -C website/public/playground pkg + gh release create "$tag" playground-pkg.tar.gz \ + --prerelease \ + --title "Playground WASM $version (${GITHUB_SHA::7})" \ + --notes "WASM bundle for openapi-to-rust.dev/playground, built from ${GITHUB_SHA}." + echo "$tag" > website/playground-wasm.lock + + - name: Commit lock bump + run: | + set -euo pipefail + if git diff --quiet -- website/playground-wasm.lock; then + echo "lock unchanged; nothing to deploy" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add website/playground-wasm.lock + git commit -m "chore: bump playground wasm to $(cat website/playground-wasm.lock) [skip ci]" + git pull --rebase origin main + git push origin main diff --git a/.gitignore b/.gitignore index 32852db..22fe69d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ /website/dist/ /website/.astro/ /website/.vercel/ + +# Playground WASM bundle: built by scripts/build-playground-wasm.sh locally, +# published as a GitHub release asset by .github/workflows/playground-wasm.yml +# and fetched by website/scripts/fetch-playground-wasm.mjs on Vercel. +/website/public/playground/pkg/ diff --git a/Cargo.lock b/Cargo.lock index 35aecf1..b74e4b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -447,7 +457,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", "tokio", "tower-service", "tracing", @@ -710,6 +720,19 @@ dependencies = [ "thiserror 1.0.69", "toml", "toml_edit", + "url", +] + +[[package]] +name = "openapi-to-rust-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "openapi-to-rust", + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", ] [[package]] @@ -771,7 +794,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -809,9 +832,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -991,6 +1014,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1082,16 +1116,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.2" @@ -1261,7 +1285,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2 0.6.2", + "socket2", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 0dc37fa..987ef06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = ["wasm"] + [package] name = "openapi-to-rust" version = "0.7.0" @@ -26,12 +29,13 @@ include = [ ] [dependencies] -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.5", features = ["derive"], optional = true } once_cell = "1.19" prettyplease = "0.2" proc-macro2 = "1.0" quote = "1.0" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"], optional = true } +url = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" @@ -50,7 +54,9 @@ insta = { version = "1.41", features = ["yaml"] } tempfile = "3.0" [features] -default = [] +default = ["cli"] +cli = ["dep:clap", "dep:reqwest", "http-error"] +http-error = ["dep:reqwest"] internal-tools = [] specta = ["dep:specta"] test-helpers = ["dep:insta", "dep:tempfile"] @@ -58,6 +64,7 @@ test-helpers = ["dep:insta", "dep:tempfile"] [[bin]] name = "openapi-to-rust" path = "src/bin/openapi-to-rust.rs" +required-features = ["cli"] [[bin]] name = "catalog-gen" diff --git a/scripts/build-playground-wasm.sh b/scripts/build-playground-wasm.sh new file mode 100755 index 0000000..b987835 --- /dev/null +++ b/scripts/build-playground-wasm.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the playground WASM bundle and stage it for the website. +# +# Output: website/public/playground/pkg/ (gitignored build artifact). +# wasm-pack runs wasm-opt -Oz itself (configured in wasm/Cargo.toml), and the +# env vars below give the release profile size-focused settings without +# touching the workspace profile used by the CLI. +set -euo pipefail +cd "$(dirname "$0")/.." + +DEST="website/public/playground/pkg" + +CARGO_PROFILE_RELEASE_OPT_LEVEL=z \ +CARGO_PROFILE_RELEASE_LTO=true \ +CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \ + wasm-pack build wasm --release --target web --out-dir "../$DEST" + +echo +echo "Playground WASM staged in $DEST:" +ls -lh "$DEST" | awk 'NR>1 {print " " $9 " (" $5 ")"}' diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index 77bea5c..ba1ff7a 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -1,11 +1,12 @@ use clap::{Parser, Subcommand}; -use openapi_to_rust::cli::{load_spec, parse_spec, sanitize_source_provenance}; +use openapi_to_rust::cli::load_spec; use openapi_to_rust::server::{ OperationIndex, Selector, edit::Editor as ServerEditor, list::{ListFilter, ListOutput, render as render_list}, resolve as resolve_selectors, }; +use openapi_to_rust::spec_source::{parse_spec, sanitize_source_provenance}; use openapi_to_rust::{CodeGenerator, ConfigFile, GeneratorConfig, SchemaAnalyzer}; use serde::Serialize; use std::path::PathBuf; @@ -342,7 +343,7 @@ fn run_generate(args: GenerateArgs) -> Result<(), Box> { let spec_content = load_spec(&load_source)?; let spec_value = parse_spec(&spec_content, &load_source)?; - let warning = validate_oas_document(&spec_value)?; + let warning = openapi_to_rust::spec_source::validate_oas_document(&spec_value)?; let mapper = openapi_to_rust::TypeMapper::new(generator_config.types.clone()); let mut analyzer = if generator_config.schema_extensions.is_empty() { SchemaAnalyzer::with_type_mapper(spec_value, mapper)? @@ -431,33 +432,6 @@ fn raw_config_spec_source(path: &std::path::Path) -> Result Result, Box> { - let version = value - .get("openapi") - .and_then(|value| value.as_str()) - .unwrap_or(""); - match openapi_to_rust::cli::parse_oas_version(version) { - Some((3, 0 | 1)) => Ok(None), - Some((3, 2)) => Ok(Some(format!( - "OpenAPI {version} support is experimental; some 3.2-only features are not generated" - ))), - Some((major, minor)) => Err(format!( - "unsupported OpenAPI version {major}.{minor} ({version:?}); expected 3.0, 3.1, or experimental 3.2" - ) - .into()), - None => { - let hint = if value.get("swagger").is_some() { - " (the document appears to be Swagger 2.0)" - } else { - "" - }; - Err(format!("missing or unrecognized `openapi` version{hint}").into()) - } - } -} - fn write_artifacts( output_dir: &std::path::Path, artifacts: &std::collections::BTreeMap, @@ -547,7 +521,7 @@ fn run_init(args: InitArgs) -> Result<(), Box> { } let content = load_spec(&args.source)?; let value = parse_spec(&content, &args.source)?; - let warning = validate_oas_document(&value)?; + let warning = openapi_to_rust::spec_source::validate_oas_document(&value)?; let spec_path = starter_spec_source(&args.source, &args.config)?; let starter = StarterConfig { @@ -606,7 +580,7 @@ fn starter_spec_source( source: &str, config: &std::path::Path, ) -> Result> { - if openapi_to_rust::cli::is_remote_spec(source) { + if openapi_to_rust::spec_source::is_remote_spec(source) { return Ok(source.to_string()); } let source_path = PathBuf::from(source); diff --git a/src/cli.rs b/src/cli.rs index c43d056..8b5dd10 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +use crate::spec_source::{is_remote_spec, parse_oas_version, parse_spec, validate_remote_spec_url}; use crate::{CodeGenerator, GeneratorConfig, SchemaAnalyzer, streaming::StreamingConfig}; use clap::{Arg, Command}; use std::fs; @@ -255,64 +256,6 @@ pub async fn run_generation_cli(cli_config: CliConfig) { } } -/// Whether an input string names a supported remote OpenAPI source. -pub fn is_remote_spec(input: &str) -> bool { - reqwest::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http")) -} - -/// Parse and enforce the remote-source transport policy. -pub fn validate_remote_spec_url(input: &str) -> Result { - let url = reqwest::Url::parse(input) - .map_err(|error| format!("invalid remote OpenAPI URL: {error}"))?; - if !url.username().is_empty() || url.password().is_some() { - return Err("remote OpenAPI URLs must not contain embedded credentials".to_string()); - } - match url.scheme() { - "https" => Ok(url), - "http" if is_loopback_host(url.host_str()) => Ok(url), - "http" => Err( - "remote OpenAPI URLs must use HTTPS (plain HTTP is allowed only for localhost/loopback)" - .to_string(), - ), - scheme => Err(format!( - "unsupported OpenAPI URL scheme `{scheme}`; use HTTPS or a local file path" - )), - } -} - -/// Remove URL credentials, query strings, and fragments before recording a -/// source label in generated code. Local paths are retained as supplied. -pub fn sanitize_source_provenance(input: &str) -> String { - let sanitize_controls = |value: &str| { - value - .chars() - .map(|character| { - if character.is_control() { - '�' - } else { - character - } - }) - .collect::() - }; - let Ok(mut url) = reqwest::Url::parse(input) else { - return sanitize_controls(input); - }; - if !matches!(url.scheme(), "https" | "http") { - return sanitize_controls(input); - } - let query_was_redacted = url.query().is_some(); - let _ = url.set_username(""); - let _ = url.set_password(None); - url.set_query(None); - url.set_fragment(None); - let mut label = url.to_string(); - if query_was_redacted { - label.push_str(" (query redacted)"); - } - sanitize_controls(&label) -} - /// Load a local or remote OpenAPI document with bounded remote I/O. /// /// HTTPS is accepted everywhere. Plain HTTP is accepted only for loopback @@ -375,182 +318,3 @@ pub fn load_spec(input: &str) -> Result> { } Ok(String::from_utf8(bytes)?) } - -fn is_loopback_host(host: Option<&str>) -> bool { - match host { - Some("localhost") => true, - Some(host) => host - .parse::() - .is_ok_and(|address| address.is_loopback()), - None => false, - } -} - -/// Parse the `openapi` version string into (major, minor). Tolerates patch and -/// build-metadata suffixes. Returns None for unrecognised input. -pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> { - let mut parts = s.split('.'); - let major = parts.next()?.parse().ok()?; - let minor_raw = parts.next()?; - let minor_digits: String = minor_raw - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); - let minor = minor_digits.parse().ok()?; - Some((major, minor)) -} - -pub fn parse_spec( - content: &str, - input: &str, -) -> Result> { - // Determine format from extension or content - let is_yaml = input.ends_with(".yaml") - || input.ends_with(".yml") - || content.trim_start().starts_with("openapi:") - || content.trim_start().starts_with("swagger:"); - - if is_yaml { - let value = yaml_to_json_value(content)?; - Ok(value) - } else { - let value = json_from_str_lossy(content)?; - Ok(value) - } -} - -/// Parse YAML to serde_json::Value, converting large numbers to f64 to avoid overflow. -/// serde_yaml 0.9 cannot represent integers exceeding i64/u64 range (e.g. numbers > 2^64), -/// so we preprocess the YAML to convert such numbers to float notation, then go through -/// serde_yaml::Value and convert to serde_json::Value manually. -pub fn yaml_to_json_value(content: &str) -> Result> { - let preprocessed = sanitize_large_yaml_integers(content); - let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?; - Ok(yaml_value_to_json(yaml_value)) -} - -/// Parse JSON with lossy number handling: numbers that overflow i64/u64 are stored as f64. -pub fn json_from_str_lossy(content: &str) -> Result> { - // Try normal parsing first (fast path) - match serde_json::from_str::(content) { - Ok(v) => Ok(v), - Err(e) => { - let err_msg = e.to_string(); - if err_msg.contains("number out of range") { - // Fall back: parse via YAML which handles large numbers - let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?; - Ok(yaml_value_to_json(yaml_value)) - } else { - Err(e.into()) - } - } - } -} - -fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value { - match yaml { - serde_yaml::Value::Null => serde_json::Value::Null, - serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b), - serde_yaml::Value::Number(n) => { - if let Some(i) = n.as_i64() { - serde_json::Value::Number(i.into()) - } else if let Some(u) = n.as_u64() { - serde_json::Value::Number(u.into()) - } else if let Some(f) = n.as_f64() { - serde_json::json!(f) - } else { - // Fallback: represent as 0.0 - serde_json::json!(0.0) - } - } - serde_yaml::Value::String(s) => serde_json::Value::String(s), - serde_yaml::Value::Sequence(seq) => { - serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect()) - } - serde_yaml::Value::Mapping(map) => { - let obj = map - .into_iter() - .filter_map(|(k, v)| { - let key = match k { - serde_yaml::Value::String(s) => s, - serde_yaml::Value::Number(n) => n.to_string(), - serde_yaml::Value::Bool(b) => b.to_string(), - _ => return None, - }; - Some((key, yaml_value_to_json(v))) - }) - .collect(); - serde_json::Value::Object(obj) - } - serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value), - } -} - -/// Preprocess YAML content to convert integers that exceed i64/u64 range to float notation. -/// serde_yaml 0.9 cannot parse integers larger than u64::MAX or smaller than i64::MIN, -/// so we find bare integer values on YAML lines and append `.0` if they overflow. -fn sanitize_large_yaml_integers(content: &str) -> String { - let mut result = String::with_capacity(content.len()); - for line in content.lines() { - if let Some(sanitized) = try_sanitize_integer_line(line) { - result.push_str(&sanitized); - } else { - result.push_str(line); - } - result.push('\n'); - } - result -} - -/// If a YAML line has a `key: ` pattern where the integer overflows i64/u64, -/// convert it to float by appending `.0`. Returns None if no change needed. -fn try_sanitize_integer_line(line: &str) -> Option { - // Match pattern: optional whitespace, key, colon, space(s), then a number value - // We look for the value portion after the last `: ` or `- ` on the line - let trimmed = line.trim(); - - // Skip comments and empty lines - if trimmed.is_empty() || trimmed.starts_with('#') { - return None; - } - - // Find the value part — after `: ` for mapping entries - let colon_pos = line.find(": ")?; - let value_start = colon_pos + 2; - let value_str = line[value_start..].trim(); - - // Check if the value looks like a bare integer (optional leading minus, then digits) - if value_str.is_empty() { - return None; - } - - let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') { - (true, rest) - } else { - (false, value_str) - }; - - // Must be all digits - if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() { - return None; - } - - // Check if it overflows i64/u64 - let overflows = if is_negative { - // Check if |value| > i64::MAX + 1 = 9223372036854775808 - digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808") - } else { - // Check if value > u64::MAX = 18446744073709551615 - digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615") - }; - - if overflows { - // Replace the integer with float notation - let mut sanitized = line[..value_start].to_string(); - sanitized.push_str(value_str); - sanitized.push_str(".0"); - Some(sanitized) - } else { - None - } -} diff --git a/src/config.rs b/src/config.rs index 73020a4..8031a49 100644 --- a/src/config.rs +++ b/src/config.rs @@ -648,12 +648,12 @@ impl ConfigFile { let mut errors = Vec::new(); let spec_source = self.generator.spec_path.to_string_lossy(); - if crate::cli::is_remote_spec(&spec_source) { - if let Err(error) = crate::cli::validate_remote_spec_url(&spec_source) { + if crate::spec_source::is_remote_spec(&spec_source) { + if let Err(error) = crate::spec_source::validate_remote_spec_url(&spec_source) { errors.push(format!("generator.spec_path: {error}")); } } else if spec_source.contains("://") { - let error = crate::cli::validate_remote_spec_url(&spec_source) + let error = crate::spec_source::validate_remote_spec_url(&spec_source) .err() .unwrap_or_else(|| "unsupported remote OpenAPI URL".to_string()); errors.push(format!("generator.spec_path: {error}")); @@ -693,7 +693,7 @@ impl ConfigFile { if let Some(http) = &self.http_client { if let Some(base_url) = &http.base_url - && reqwest::Url::parse(base_url).is_err() + && url::Url::parse(base_url).is_err() { errors.push("http_client.base_url: base_url must be a valid URL".to_string()); } diff --git a/src/lib.rs b/src/lib.rs index 1808a0d..f81a42a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ //! with [`CodeGenerator::write_files`]. pub mod analysis; +#[cfg(feature = "cli")] pub mod cli; pub mod client_generator; pub mod config; @@ -52,11 +53,13 @@ pub mod error; pub mod extensions; pub mod generator; pub mod http_config; +#[cfg(feature = "http-error")] pub mod http_error; pub mod openapi; pub mod patterns; pub mod registry_generator; pub mod server; +pub mod spec_source; pub mod streaming; pub mod type_mapping; @@ -68,11 +71,16 @@ pub mod type_mapping; #[cfg(feature = "test-helpers")] pub mod test_helpers; +/// Crate version, exposed so embedders (e.g. the WASM playground) can report +/// which generator produced their output. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + pub use analysis::{SchemaAnalysis, SchemaAnalyzer, merge_schema_extensions}; pub use config::ConfigFile; pub use error::GeneratorError; pub use generator::{CodeGenerator, GeneratedFile, GenerationResult, GeneratorConfig}; pub use http_config::{AuthConfig, HttpClientConfig, RetryConfig}; +#[cfg(feature = "http-error")] pub use http_error::{ApiError, ApiOpError, HttpError, HttpResult}; pub use openapi::{OpenApiSpec, Schema, SchemaType}; pub use type_mapping::{ diff --git a/src/spec_source.rs b/src/spec_source.rs new file mode 100644 index 0000000..ef8db9d --- /dev/null +++ b/src/spec_source.rs @@ -0,0 +1,271 @@ +//! Spec-source policy and document parsing shared by the CLI, library +//! consumers, and the WASM playground build. +//! +//! Everything in this module is pure: URL policy checks build on `url::Url` +//! and document parsing goes through `serde_yaml`/`serde_json` in memory. The +//! I/O that actually fetches or reads a spec lives in [`crate::cli`], which is +//! gated behind the `cli` feature. + +/// Whether an input string names a supported remote OpenAPI source. +pub fn is_remote_spec(input: &str) -> bool { + url::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http")) +} + +/// Parse and enforce the remote-source transport policy. +pub fn validate_remote_spec_url(input: &str) -> Result { + let url = + url::Url::parse(input).map_err(|error| format!("invalid remote OpenAPI URL: {error}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err("remote OpenAPI URLs must not contain embedded credentials".to_string()); + } + match url.scheme() { + "https" => Ok(url), + "http" if is_loopback_host(url.host_str()) => Ok(url), + "http" => Err( + "remote OpenAPI URLs must use HTTPS (plain HTTP is allowed only for localhost/loopback)" + .to_string(), + ), + scheme => Err(format!( + "unsupported OpenAPI URL scheme `{scheme}`; use HTTPS or a local file path" + )), + } +} + +/// Remove URL credentials, query strings, and fragments before recording a +/// source label in generated code. Local paths are retained as supplied. +pub fn sanitize_source_provenance(input: &str) -> String { + let sanitize_controls = |value: &str| { + value + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect::() + }; + let Ok(mut url) = url::Url::parse(input) else { + return sanitize_controls(input); + }; + if !matches!(url.scheme(), "https" | "http") { + return sanitize_controls(input); + } + let query_was_redacted = url.query().is_some(); + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + let mut label = url.to_string(); + if query_was_redacted { + label.push_str(" (query redacted)"); + } + sanitize_controls(&label) +} +fn is_loopback_host(host: Option<&str>) -> bool { + match host { + Some("localhost") => true, + Some(host) => host + .parse::() + .is_ok_and(|address| address.is_loopback()), + None => false, + } +} + +/// Parse the `openapi` version string into (major, minor). Tolerates patch and +/// build-metadata suffixes. Returns None for unrecognised input. +pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> { + let mut parts = s.split('.'); + let major = parts.next()?.parse().ok()?; + let minor_raw = parts.next()?; + let minor_digits: String = minor_raw + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + let minor = minor_digits.parse().ok()?; + Some((major, minor)) +} + +pub fn parse_spec( + content: &str, + input: &str, +) -> Result> { + // Determine format from extension or content + let is_yaml = input.ends_with(".yaml") + || input.ends_with(".yml") + || content.trim_start().starts_with("openapi:") + || content.trim_start().starts_with("swagger:"); + + if is_yaml { + let value = yaml_to_json_value(content)?; + Ok(value) + } else { + let value = json_from_str_lossy(content)?; + Ok(value) + } +} + +/// Parse YAML to serde_json::Value, converting large numbers to f64 to avoid overflow. +/// serde_yaml 0.9 cannot represent integers exceeding i64/u64 range (e.g. numbers > 2^64), +/// so we preprocess the YAML to convert such numbers to float notation, then go through +/// serde_yaml::Value and convert to serde_json::Value manually. +pub fn yaml_to_json_value(content: &str) -> Result> { + let preprocessed = sanitize_large_yaml_integers(content); + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?; + Ok(yaml_value_to_json(yaml_value)) +} + +/// Parse JSON with lossy number handling: numbers that overflow i64/u64 are stored as f64. +pub fn json_from_str_lossy(content: &str) -> Result> { + // Try normal parsing first (fast path) + match serde_json::from_str::(content) { + Ok(v) => Ok(v), + Err(e) => { + let err_msg = e.to_string(); + if err_msg.contains("number out of range") { + // Fall back: parse via YAML which handles large numbers + let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?; + Ok(yaml_value_to_json(yaml_value)) + } else { + Err(e.into()) + } + } + } +} + +fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value { + match yaml { + serde_yaml::Value::Null => serde_json::Value::Null, + serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + serde_json::Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + serde_json::Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::json!(f) + } else { + // Fallback: represent as 0.0 + serde_json::json!(0.0) + } + } + serde_yaml::Value::String(s) => serde_json::Value::String(s), + serde_yaml::Value::Sequence(seq) => { + serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect()) + } + serde_yaml::Value::Mapping(map) => { + let obj = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + serde_yaml::Value::String(s) => s, + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + _ => return None, + }; + Some((key, yaml_value_to_json(v))) + }) + .collect(); + serde_json::Value::Object(obj) + } + serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value), + } +} + +/// Preprocess YAML content to convert integers that exceed i64/u64 range to float notation. +/// serde_yaml 0.9 cannot parse integers larger than u64::MAX or smaller than i64::MIN, +/// so we find bare integer values on YAML lines and append `.0` if they overflow. +fn sanitize_large_yaml_integers(content: &str) -> String { + let mut result = String::with_capacity(content.len()); + for line in content.lines() { + if let Some(sanitized) = try_sanitize_integer_line(line) { + result.push_str(&sanitized); + } else { + result.push_str(line); + } + result.push('\n'); + } + result +} + +/// If a YAML line has a `key: ` pattern where the integer overflows i64/u64, +/// convert it to float by appending `.0`. Returns None if no change needed. +fn try_sanitize_integer_line(line: &str) -> Option { + // Match pattern: optional whitespace, key, colon, space(s), then a number value + // We look for the value portion after the last `: ` or `- ` on the line + let trimmed = line.trim(); + + // Skip comments and empty lines + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + // Find the value part — after `: ` for mapping entries + let colon_pos = line.find(": ")?; + let value_start = colon_pos + 2; + let value_str = line[value_start..].trim(); + + // Check if the value looks like a bare integer (optional leading minus, then digits) + if value_str.is_empty() { + return None; + } + + let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') { + (true, rest) + } else { + (false, value_str) + }; + + // Must be all digits + if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() { + return None; + } + + // Check if it overflows i64/u64 + let overflows = if is_negative { + // Check if |value| > i64::MAX + 1 = 9223372036854775808 + digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808") + } else { + // Check if value > u64::MAX = 18446744073709551615 + digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615") + }; + + if overflows { + // Replace the integer with float notation + let mut sanitized = line[..value_start].to_string(); + sanitized.push_str(value_str); + sanitized.push_str(".0"); + Some(sanitized) + } else { + None + } +} + +/// Validate the `openapi` version field of a parsed document. +/// +/// Returns an optional warning for experimental versions (3.2) and an error +/// for unsupported or missing versions. +pub fn validate_oas_document(value: &serde_json::Value) -> Result, String> { + let version = value + .get("openapi") + .and_then(|value| value.as_str()) + .unwrap_or(""); + match parse_oas_version(version) { + Some((3, 0 | 1)) => Ok(None), + Some((3, 2)) => Ok(Some(format!( + "OpenAPI {version} support is experimental; some 3.2-only features are not generated" + ))), + Some((major, minor)) => Err(format!( + "unsupported OpenAPI version {major}.{minor} ({version:?}); expected 3.0, 3.1, or experimental 3.2" + )), + None => { + let hint = if value.get("swagger").is_some() { + " (the document appears to be Swagger 2.0)" + } else { + "" + }; + Err(format!("missing or unrecognized `openapi` version{hint}")) + } + } +} diff --git a/tests/cli_workflow_test.rs b/tests/cli_workflow_test.rs index f5d361f..93616f5 100644 --- a/tests/cli_workflow_test.rs +++ b/tests/cli_workflow_test.rs @@ -1,5 +1,6 @@ use openapi_to_rust::ConfigFile; -use openapi_to_rust::cli::{MAX_REMOTE_SPEC_BYTES, load_spec, validate_remote_spec_url}; +use openapi_to_rust::cli::{MAX_REMOTE_SPEC_BYTES, load_spec}; +use openapi_to_rust::spec_source::validate_remote_spec_url; use std::io::{Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; diff --git a/tests/server_config_test.rs b/tests/server_config_test.rs index c65d77d..dab256b 100644 --- a/tests/server_config_test.rs +++ b/tests/server_config_test.rs @@ -31,9 +31,9 @@ fn load_index(spec_path: &std::path::Path) -> OperationIndex { let value: serde_json::Value = if spec_path.extension().and_then(|e| e.to_str()) == Some("yaml") || spec_path.extension().and_then(|e| e.to_str()) == Some("yml") { - openapi_to_rust::cli::yaml_to_json_value(&body).unwrap() + openapi_to_rust::spec_source::yaml_to_json_value(&body).unwrap() } else { - openapi_to_rust::cli::json_from_str_lossy(&body).unwrap() + openapi_to_rust::spec_source::json_from_str_lossy(&body).unwrap() }; let mut analyzer = SchemaAnalyzer::new(value).unwrap(); let analysis = analyzer.analyze().unwrap(); diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml new file mode 100644 index 0000000..2c35d69 --- /dev/null +++ b/wasm/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openapi-to-rust-wasm" +version = "0.1.0" +edition = "2024" +publish = false +description = "WASM bindings for the openapi-to-rust playground on openapi-to-rust.dev" +license = "MIT" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openapi-to-rust = { path = "..", default-features = false } +wasm-bindgen = "0.2" +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6" +serde_json = "1.0" +console_error_panic_hook = "0.1" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = ["-Oz"] diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs new file mode 100644 index 0000000..8d40798 --- /dev/null +++ b/wasm/src/lib.rs @@ -0,0 +1,180 @@ +//! WASM bindings for the openapi-to-rust playground. +//! +//! Wraps the in-memory generation pipeline (`SchemaAnalyzer` → +//! `CodeGenerator::generate_all`) for the browser. No filesystem or network +//! access — the page hands us a spec string, we hand back rendered files. + +use openapi_to_rust::spec_source::{ + json_from_str_lossy, sanitize_source_provenance, validate_oas_document, yaml_to_json_value, +}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde::Serialize; +use wasm_bindgen::prelude::*; + +/// Module name used for the generated tree, matching the CLI's spec-argument +/// default (`openapi-to-rust generate `). +const MODULE_NAME: &str = "api"; + +#[derive(Serialize)] +struct PlaygroundFile { + name: String, + content: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlaygroundOutput { + /// Files as the CLI writes them into the output directory. + files: Vec, + /// A complete, compilable crate: Cargo.toml, src/lib.rs, src/api/*. + crate_files: Vec, + crate_name: String, + /// Non-fatal warning (e.g. experimental OpenAPI 3.2 support). + warning: Option, + schemas: usize, + operations: usize, +} + +#[wasm_bindgen(start)] +pub fn start() { + console_error_panic_hook::set_once(); +} + +/// Version of the underlying openapi-to-rust generator crate. +#[wasm_bindgen] +pub fn version() -> String { + openapi_to_rust::VERSION.to_string() +} + +/// Generate Rust source files from an OpenAPI document (YAML or JSON). +/// +/// `source_label` names where the document came from (a URL, or a playground +/// marker for pasted content) and is recorded in generated file headers the +/// same way the CLI records the spec path. +/// +/// Returns `{ files, crateFiles, crateName, warning, schemas, operations }`. +/// Errors are thrown as JS `Error`s carrying the generator's message. +#[wasm_bindgen] +pub fn generate(spec: &str, source_label: &str) -> Result { + let trimmed = spec.trim_start(); + if trimmed.is_empty() { + return Err(JsError::new("the OpenAPI document is empty")); + } + // JSON documents start with `{`; everything else goes through the YAML + // path, which applies the large-integer sanitization the CLI uses. + let value = if trimmed.starts_with('{') { + json_from_str_lossy(spec) + } else { + yaml_to_json_value(spec) + } + .map_err(|error| JsError::new(&format!("failed to parse the document: {error}")))?; + + let warning = validate_oas_document(&value).map_err(|message| JsError::new(&message))?; + let crate_name = crate_name_from_spec(&value); + + let mut analyzer = + SchemaAnalyzer::new(value).map_err(|error| JsError::new(&error.to_string()))?; + let mut analysis = analyzer + .analyze() + .map_err(|error| JsError::new(&error.to_string()))?; + + // Mirror the CLI's `generate ` configuration so playground output + // is byte-identical to a local run (modulo the source label). + let config = GeneratorConfig { + module_name: MODULE_NAME.to_string(), + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + ..GeneratorConfig::default() + }; + let generator = + CodeGenerator::new(config).with_source_provenance(sanitize_source_provenance(source_label)); + let result = generator + .generate_all(&mut analysis) + .map_err(|error| JsError::new(&error.to_string()))?; + + let files: Vec = generator + .output_artifacts(&result) + .into_iter() + .map(|(path, content)| PlaygroundFile { + name: path.display().to_string(), + content, + }) + .collect(); + let crate_files = crate_files(&crate_name, &files); + + let output = PlaygroundOutput { + files, + crate_files, + crate_name, + warning, + schemas: analysis.schemas.len(), + operations: analysis.operations.len(), + }; + serde_wasm_bindgen::to_value(&output).map_err(|error| JsError::new(&error.to_string())) +} + +/// Derive a cargo package name from the spec's `info.title`. +fn crate_name_from_spec(value: &serde_json::Value) -> String { + let title = value + .get("info") + .and_then(|info| info.get("title")) + .and_then(|title| title.as_str()) + .unwrap_or(""); + let mut name = String::new(); + let mut previous_dash = true; + for character in title.chars() { + if character.is_ascii_alphanumeric() { + name.push(character.to_ascii_lowercase()); + previous_dash = false; + } else if !previous_dash { + name.push('-'); + previous_dash = true; + } + } + let name = name.trim_matches('-'); + if name.is_empty() { + "generated-api".to_string() + } else if name.starts_with(|c: char| c.is_ascii_digit()) { + format!("api-{name}") + } else { + name.to_string() + } +} + +/// Assemble a complete crate from the CLI-shaped output files: the generated +/// tree mounts at `src/api/`, `src/lib.rs` re-exports it, and Cargo.toml is +/// the REQUIRED_DEPS fragment under a package header. +fn crate_files(crate_name: &str, files: &[PlaygroundFile]) -> Vec { + let mut out = Vec::new(); + let mut dependencies_section = String::from("[dependencies]\n"); + for file in files { + if file.name == "REQUIRED_DEPS.toml" { + if let Some(index) = file.content.find("[dependencies]") { + dependencies_section = file.content[index..].to_string(); + } + } else { + out.push(PlaygroundFile { + name: format!("src/{MODULE_NAME}/{}", file.name), + content: file.content.clone(), + }); + } + } + out.push(PlaygroundFile { + name: "Cargo.toml".to_string(), + content: format!( + "[package]\n\ + name = \"{crate_name}\"\n\ + version = \"0.1.0\"\n\ + edition = \"2021\"\n\ + \n\ + {dependencies_section}" + ), + }); + out.push(PlaygroundFile { + name: "src/lib.rs".to_string(), + content: format!("pub mod {MODULE_NAME};\n"), + }); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} diff --git a/website/package-lock.json b/website/package-lock.json index 5402fce..983e817 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -11,6 +11,7 @@ "@astrojs/check": "0.9.9", "@astrojs/sitemap": "3.7.3", "astro": "7.0.9", + "highlight.js": "^11.11.1", "sharp": "0.34.5", "typescript": "6.0.3" }, @@ -3214,6 +3215,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", diff --git a/website/package.json b/website/package.json index 494d21c..a2030dc 100644 --- a/website/package.json +++ b/website/package.json @@ -4,7 +4,9 @@ "private": true, "type": "module", "scripts": { + "predev": "node scripts/fetch-playground-wasm.mjs", "dev": "astro dev", + "prebuild": "node scripts/fetch-playground-wasm.mjs", "build": "astro check && astro build", "check": "astro check", "audit:html": "html-validate 'dist/**/*.html'", @@ -15,6 +17,7 @@ "@astrojs/check": "0.9.9", "@astrojs/sitemap": "3.7.3", "astro": "7.0.9", + "highlight.js": "^11.11.1", "sharp": "0.34.5", "typescript": "6.0.3" }, diff --git a/website/playground-wasm.lock b/website/playground-wasm.lock new file mode 100644 index 0000000..b9b8b8f --- /dev/null +++ b/website/playground-wasm.lock @@ -0,0 +1 @@ +playground-wasm-0.7.0-bootstrap diff --git a/website/public/playground/examples/multiple_operations.json b/website/public/playground/examples/multiple_operations.json new file mode 100644 index 0000000..6800b42 --- /dev/null +++ b/website/public/playground/examples/multiple_operations.json @@ -0,0 +1,137 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test", + "version": "1.0.0" + }, + "paths": { + "/items/{id}": { + "get": { + "operationId": "getItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + } + } + } + }, + "put": { + "operationId": "updateItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateItemRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + }, + "post": { + "operationId": "duplicateItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Item": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "UpdateItemRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/website/public/playground/examples/path_params.json b/website/public/playground/examples/path_params.json new file mode 100644 index 0000000..d01e487 --- /dev/null +++ b/website/public/playground/examples/path_params.json @@ -0,0 +1,51 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test", + "version": "1.0.0" + }, + "paths": { + "/items/{itemId}": { + "get": { + "operationId": "getItem", + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Item": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/website/public/playground/examples/simple_get.json b/website/public/playground/examples/simple_get.json new file mode 100644 index 0000000..7cfadc7 --- /dev/null +++ b/website/public/playground/examples/simple_get.json @@ -0,0 +1,44 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test", + "version": "1.0.0" + }, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Item" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Item": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/website/public/playground/worker.js b/website/public/playground/worker.js new file mode 100644 index 0000000..4517c1e --- /dev/null +++ b/website/public/playground/worker.js @@ -0,0 +1,20 @@ +// Playground generation worker. Plain JS on purpose: this file is served +// verbatim from public/ and imports the wasm-pack bundle at runtime, so the +// site bundler never needs to understand the wasm module graph. +import init, { generate, version } from "/playground/pkg/openapi_to_rust_wasm.js"; + +const ready = init().then(() => { + postMessage({ type: "ready", version: version() }); +}); + +addEventListener("message", async (event) => { + const { id, spec, sourceLabel } = event.data; + try { + await ready; + const result = generate(spec, sourceLabel); + postMessage({ type: "result", id, ok: true, result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + postMessage({ type: "result", id, ok: false, error: message }); + } +}); diff --git a/website/scripts/fetch-playground-wasm.mjs b/website/scripts/fetch-playground-wasm.mjs new file mode 100644 index 0000000..f4a1084 --- /dev/null +++ b/website/scripts/fetch-playground-wasm.mjs @@ -0,0 +1,51 @@ +// Fetch the playground WASM bundle pinned by playground-wasm.lock. +// +// Runs as npm predev/prebuild. No-op when public/playground/pkg/ already +// exists (a local scripts/build-playground-wasm.sh build wins); otherwise +// downloads the pinned release asset so Vercel builds and toolchain-less +// clones get a working playground. Fails loudly rather than building a +// playground page with no wasm behind it. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const websiteDir = dirname(dirname(fileURLToPath(import.meta.url))); +const pkgDir = join(websiteDir, "public", "playground", "pkg"); +const lockPath = join(websiteDir, "playground-wasm.lock"); + +if (existsSync(join(pkgDir, "openapi_to_rust_wasm_bg.wasm"))) { + console.log("playground wasm: using existing local build in public/playground/pkg/"); + process.exit(0); +} + +const tag = readFileSync(lockPath, "utf8").trim(); +if (!tag) { + console.error(`playground wasm: ${lockPath} is empty`); + process.exit(1); +} + +const url = `https://github.com/gpu-cli/openapi-to-rust/releases/download/${tag}/playground-pkg.tar.gz`; +console.log(`playground wasm: fetching ${url}`); + +const response = await fetch(url, { redirect: "follow" }); +if (!response.ok) { + console.error( + `playground wasm: download failed with HTTP ${response.status}. ` + + `The release asset for ${tag} is missing — check the playground-wasm workflow.`, + ); + process.exit(1); +} + +const archivePath = join(websiteDir, "playground-pkg.tar.gz"); +writeFileSync(archivePath, Buffer.from(await response.arrayBuffer())); +rmSync(pkgDir, { recursive: true, force: true }); +mkdirSync(dirname(pkgDir), { recursive: true }); +execFileSync("tar", ["xzf", archivePath, "-C", dirname(pkgDir)], { stdio: "inherit" }); +rmSync(archivePath); + +if (!existsSync(join(pkgDir, "openapi_to_rust_wasm_bg.wasm"))) { + console.error("playground wasm: archive did not contain pkg/openapi_to_rust_wasm_bg.wasm"); + process.exit(1); +} +console.log(`playground wasm: staged ${tag} into public/playground/pkg/`); diff --git a/website/src/components/SiteHeader.astro b/website/src/components/SiteHeader.astro index ebc428d..36ce314 100644 --- a/website/src/components/SiteHeader.astro +++ b/website/src/components/SiteHeader.astro @@ -3,6 +3,7 @@ const path = Astro.url.pathname; const links = [ { href: "/docs", label: "Docs" }, + { href: "/playground", label: "Playground" }, { href: "/compatibility", label: "Compatibility" }, { href: "/compare/openapi-generator", label: "Compare" }, ]; diff --git a/website/src/pages/playground.astro b/website/src/pages/playground.astro new file mode 100644 index 0000000..f1115f3 --- /dev/null +++ b/website/src/pages/playground.astro @@ -0,0 +1,602 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; + +const examples = [ + { value: "simple_get.json", label: "Simple GET (JSON)" }, + { value: "path_params.json", label: "Path parameters (JSON)" }, + { value: "multiple_operations.json", label: "Multiple operations (JSON)" }, +]; + +const anthropicSpecUrl = + "https://raw.githubusercontent.com/gpu-cli/openapi-to-rust/main/tests/fixtures/anthropic.yml"; +--- + + +
+
+
+

Playground

+

+ Paste an OpenAPI document — or fetch one by URL — and see the exact Rust the generator + emits. The real generator runs in your browser as WebAssembly; nothing is uploaded. +

+
+
+ +
+
+
+ +
+
+ + +
+ + +
+ + or press Ctrl/Cmd + Enter +
+
+ +
+ + +
+

Generated files appear here.

+

Pick an example on the left to see typed models, an async client, and the dependency fragment the generator produces.

+
+ +
+
+ + +
+
+ + + + + + From 44e33d86bd93182458242f34bdd03c033dc32c7e Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 17 Jul 2026 20:11:29 -0600 Subject: [PATCH 2/9] chore: sync beads metadata Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a8996d1..cbfe2db 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"id":"openapi-generator-5gn","title":"Playground page: /playground on Astro site","description":"website/src/pages/playground.astro: paste textarea, URL fetch w/ CORS fallback message, example dropdown, Web Worker generation, tabbed output w/ copy, version footer, in-page generator errors. No any types, static output, lazy wasm.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:44Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:01Z","closed_at":"2026-07-18T02:10:01Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-zkx","title":"wasm/ crate: wasm-bindgen wrapper + build script","description":"cdylib workspace member openapi-to-rust-wasm exporting generate(spec, configToml) and version(); scripts/build-playground-wasm.sh: wasm-pack build --target web + wasm-opt -Oz -\u003e website/public/playground/pkg/ (gitignored).","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:43Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:00Z","closed_at":"2026-07-18T02:10:00Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-0o4","title":"Feature-gate cli/reqwest/clap for wasm32 build","description":"Make reqwest+clap optional behind default-on 'cli' feature; gate src/cli.rs + http_error re-exports; config.rs uses url::Url. Gate: cargo check --target wasm32-unknown-unknown --no-default-features passes, CLI unchanged with defaults.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:42Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:00Z","closed_at":"2026-07-18T02:10:00Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-4u6","title":"Playground epic: in-browser WASM playground on website","description":"Ship /playground on the Astro site: paste/fetch an OpenAPI spec, see generated Rust from the real generator compiled to WASM. Local-first, then live via artifact-pull pipeline (no Vercel token). See conversation goal text.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:39:37Z","created_by":"James Lal","updated_at":"2026-07-18T01:39:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-4u6","title":"Playground epic: in-browser WASM playground on website","description":"Ship /playground on the Astro site: paste/fetch an OpenAPI spec, see generated Rust from the real generator compiled to WASM. Local-first, then live via artifact-pull pipeline (no Vercel token). See conversation goal text.","notes":"All build tasks done and verified locally (browser-tested via Playwright; CLI parity, error paths, CORS fallback, 2.1MB OpenAI spec, downloadable crate compiles). Live rollout = merge branch to main: Vercel deploy fetches bootstrap release asset; playground-wasm.yml then rebuilds+bumps lock automatically.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:39:37Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-554","title":"Array items with inline string enum generate Vec\u003cString\u003e instead of named enum (gh#33)","description":"GitHub issue gpu-cli/openapi-to-rust#33: analyze_array_schema maps string array items straight to String, ignoring enum values, while analyze_property_schema_with_context hoists inline string enums into named types. Fix: extract the enum-hoisting logic into a shared helper and use it for array items too.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T14:40:53Z","created_by":"James Lal","updated_at":"2026-07-17T14:52:45Z","started_at":"2026-07-17T14:41:15Z","closed_at":"2026-07-17T14:52:45Z","close_reason":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/34 (fixes gh#33): array items with inline string enums now hoist to named {Parent}Item enums via shared hoist_inline_string_enum helper","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dj7","title":"Distill and harden developer-first homepage","notes":"Rewrote homepage from 1,007 to 603 visible words (40.1% reduction) and 10 to 6 sections. Hero now states exact output and two-command trial; CI proof separates active PR tier from configured scheduled/manual full-corpus tier; real model/client/server artifacts are linked; repeated eyebrow/grid/window-dot patterns removed; spacing and z-index tokens added; copy failure is visible; neutral background replaces decorative cream grid. Impeccable critique trend 30→36→38 with 0 P0/P1. Gates: Astro 0 diagnostics; 9 pages built; html-validate pass; SEO audit pass; detector and layout detector clean. Playwright MCP and isolated critique contexts verified 1440x1000, 768x1024, 390x844: no page/section/H1 overflow, no console errors, copy/menu/FAQ/focus/touch targets pass. Production deployment dpl_9qhn2wtuiYUYuPsSdduALvGUSicu READY and aliased to https://openapi-to-rust.dev; live desktop/mobile Playwright plus canonical/robots/sitemap/security-header/404 checks pass.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:53:05Z","created_by":"James Lal","updated_at":"2026-07-16T05:33:51Z","started_at":"2026-07-16T04:53:11Z","closed_at":"2026-07-16T05:33:51Z","close_reason":"Developer-first homepage meets all stopping criteria, is deployed, visually verified, and scores 38/40 with no P0/P1 findings.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-gl9","title":"Build and deploy SEO-first Astro documentation site","description":"Create an Astro landing page and documentation site for openapi-to-rust, targeting Rust OpenAPI code-generation search intent; include strong technical SEO, accessible responsive design, local QA, and Vercel deployment.","design":"Host the site in a self-contained website directory so Rust crate workflows remain isolated. Favor static HTML, minimal JavaScript, content-rich documentation, honest comparisons, and code-native visuals.","acceptance_criteria":"Astro site builds successfully; landing page targets openapi generator rust and adjacent intent; metadata, canonical URLs, sitemap, robots, structured data, and social cards are present; responsive and accessible UI is browser-tested and critiqued; deployment succeeds on Vercel; all changes are committed and pushed.","notes":"Implemented an SEO-first Astro site under website/ with 8 indexable routes plus a custom 404; canonical origin https://openapi-to-rust.dev; deployed to Vercel project lbl-rd/openapi-to-rust; Git integration root is website; custom domain verified. Validation: remote Vercel build clean, Astro check clean, SEO audit passed for 9 rendered pages, html-validate passed, live HTTP/canonical/sitemap/security-header/404 checks passed. Playwright browser bridge was present but exposed zero browser backends, so visual testing used independent source/rendered-HTML critique and OG image inspection rather than screenshots.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:03:33Z","created_by":"James Lal","updated_at":"2026-07-16T04:34:21Z","started_at":"2026-07-16T04:03:57Z","closed_at":"2026-07-16T04:34:21Z","close_reason":"SEO site implemented, critiqued, validated, deployed, and connected to the custom domain and Git repository.","dependency_count":0,"dependent_count":0,"comment_count":0} From 10886f13e469ab3281c449aba1b5329e234b2a9d Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 17 Jul 2026 22:50:22 -0600 Subject: [PATCH 3/9] fix: reliable crate download + edition 2024 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Attach the download anchor to the DOM and defer blob-URL revocation; immediate revoke after click races the browser's blob fetch and stalled downloads outside headless Chromium. Button shows Preparing… while packing. - Downloaded crate now declares edition 2024 (matches the generator repo); verified cargo build passes for simple and Anthropic-spec crates. Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 1 + wasm/src/lib.rs | 2 +- website/src/pages/playground.astro | 31 ++++++++++++++++++++---------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index cbfe2db..eaad3c3 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -46,6 +46,7 @@ {"id":"openapi-generator-st8","title":"[Q3] Builder pattern for operations with many parameters","description":"OpenAI's responses_create has 25+ parameters. Even with Option\u003cT\u003e for optionals, the call site is hostile: client.responses_create(model, None, None, ..., Some('system prompt'), None, ...). Goal: emit a \u003cOp\u003eBuilder\u003c'_\u003e per op with .field(value) setters and a final .send().await. Required path/header params remain positional on the entry method; optional + body fields become builder setters. For struct-typed bodies, also generate per-field setters on the builder (delegating into the body struct).\n\n## Context\nFiles: src/client_generator.rs. Evidence: src/client_generator.rs:836 generate_request_param emits flat positional method args. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.builders] enabled = true; threshold = 3 in TOML config.\n- [ ] Each operation with \u003ethreshold optional params gets a builder struct.\n- [ ] Required params stay positional on the entry method.\n- [ ] .send(self) -\u003e Result\u003c\u003cResponseT\u003e, ApiOpError\u003c...\u003e\u003e runs the existing emitted body.\n- [ ] Snapshot tests for an op with many optional params show the new shape compiles and the existing call compiles.\n- [ ] All 49 currently-compiling specs still compile.","notes":"Implementation plan: add strict default-disabled [generator.builders] with threshold=3; preserve every flat method; pass SchemaAnalysis into operation artifact generation; count optional operation arguments plus reachable optional body fields; emit owned \u003cOp\u003eBuilder\u003c'a\u003e values and additive *_builder entries whose send delegates to the flat method. Reuse the exact model field projection for body setters, initialize all-optional bodies with Default and mixed object bodies with Request::new(required...), retain whole-body fallback for opaque/unsupported shapes, and traverse allOf composition paths so OpenAI createResponse receives semantic field setters. Allocate entry/type/setter names collision-safely. Verify config boundaries, synthetic flat+builder scratch calls, real selective OpenAI composition calls, full gates, and the spec compile corpus.\nImplementation and focused verification complete: strict [generator.builders] config, \u003ethreshold gating, additive flat-method delegation, required positional inputs, owned setters, mixed/default/optional/composed body construction, deterministic collision handling, and selective OpenAI coverage. Evidence: cargo test --test operation_builder_test --all-features (4 passed); cargo test --test config_test --test request_model_ergonomics_test --all-features (26 passed); cargo check --no-default-features; cargo check --all-features; cargo clippy --all-features -- -D warnings; RUSTDOCFLAGS='-D warnings' cargo doc --all-features --no-deps; cargo test --all-features. The 55-spec independent corpus generated 34 specs before remaining CPU-bound for ~20 minutes analyzing the 16,153-operation Microsoft Graph spec; stopped with exit 130. Stack sampling isolated the pre-existing quadratic operation-ID collision scan and filed openapi-generator-1sb.10. Keep st8 open until that performance fix permits the complete corpus acceptance run.\nFinal acceptance evidence after openapi-generator-1sb.10: scripts/spec-compile.sh completed with 54 OpenAPI specs passed, 0 generation failures, 0 compile failures, and the one expected Swagger 2.0 skip. All builder/config/flat-call/OpenAI tests and full quality gates pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:55Z","created_by":"James Lal","updated_at":"2026-07-15T17:22:45Z","started_at":"2026-07-15T16:32:05Z","closed_at":"2026-07-15T17:22:45Z","close_reason":"Configurable additive operation builders preserve flat calls and compile across focused real-world coverage plus the complete OpenAPI corpus.","labels":["adoption","codegen","ergonomics","phase4","quality"],"dependencies":[{"issue_id":"openapi-generator-st8","depends_on_id":"openapi-generator-1sb.3","type":"parent-child","created_at":"2026-07-15T08:32:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-st8","depends_on_id":"openapi-generator-1sb.4","type":"blocks","created_at":"2026-07-15T08:39:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"id":"openapi-generator-quq","title":"[Q2] Format-typed scalars (date-time, uuid, byte, binary, ipv4, ipv6, uri)","description":"Real-world specs use 'format' tags everywhere. Today everything collapses to String/Vec\u003cu8\u003e. This issue adds typed scalars to the generator with **on-by-default** behavior and per-format opt-out via [generator.types] TOML.\n\n## Defaults (flipped to opt-out model)\n\n| format | default strategy | rust type | opt-out |\n|---|---|---|---|\n| date-time | chrono | chrono::DateTime\u003cUtc\u003e | = \"string\" or \"time\" |\n| date | chrono | chrono::NaiveDate | = \"string\" or \"time\" |\n| time | chrono | chrono::NaiveTime | = \"string\" or \"time\" |\n| duration | chrono | chrono::Duration | = \"string\" or \"iso8601\" |\n| uuid | uuid | uuid::Uuid | = \"string\" |\n| byte | base64 | Vec\u003cu8\u003e + inline base64_serde mod | = \"string\" or \"vec_u8\" |\n| binary | bytes | bytes::Bytes | = \"string\" or \"vec_u8\" |\n| ipv4/ipv6 | std | std::net::Ipv*Addr | = \"string\" |\n| uri | url | url::Url | = \"string\" |\n| email | string (off) | String | = \"email_address\" to opt in |\n\n## Implementation\n\nGoes through new TypeMapper chokepoint (see Q2.0). Each used optional crate is reported via REQUIRED_DEPS.toml (see Q2.8).\n\n## Context\nFiles: src/analysis.rs (lines 2967, 1151), src/generator.rs, src/type_mapping.rs (new). Evidence: src/analysis.rs:2973 returns bare \"String\" for OpenApiSchemaType::String regardless of format. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types] TOML section with per-format strategy strings.\n- [ ] Each format's default is on (typed) when crate is small/common; opt-out via = \"string\".\n- [ ] CLI --types-conservative flag sets all strategies back to \"string\" for regression bisects.\n- [ ] date-time uses chrono::serde::rfc3339 codec.\n- [ ] uuid uses uuid::Uuid with serde feature.\n- [ ] byte round-trips via base64 (inline mod base64_serde, no runtime crate).\n- [ ] binary uses bytes::Bytes with serde feature.\n- [ ] One conformance fixture per format under tests/conformance/fixtures/schema/format-*.yaml.\n- [ ] All 49 currently-compiling specs still compile under default config (i.e. with typed scalars on).\n- [ ] All 49 specs also still compile under --types-conservative.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:40Z","created_by":"James Lal","updated_at":"2026-05-09T08:59:12Z","started_at":"2026-05-09T06:44:01Z","closed_at":"2026-05-09T08:59:12Z","close_reason":"Q2 typed-scalar formats land with flipped defaults (chrono/uuid/url/bytes/std::net::Ip*Addr/base64+codec). TypeMappingConfig switched from Option\u003cString\u003e placeholders to enum-typed strategies (DateStrategy/UuidStrategy/ByteStrategy/...) with opt-out per format. Wired through SchemaType::Primitive's new serde_with field, surfaced via #[serde(with = ...)] in generator. base64_serde helper module (with Option submodule for nullable byte fields) emitted only when format:byte is actually used. type_lacks_default extended for chrono/url/time types. --types-conservative CLI flag collapses everything back to String for bisecting. spec-compile gate: all 54 specs pass with default typed-on config; 1 skipped (gitea, baseline). Integration suite: zero failures. New tests: 10 typed-scalar end-to-end + 7 TypeMapper unit tests. Email + duration kept off by default (email less universal; chrono::Duration's native serde is seconds, not ISO 8601 — proper duration support is a follow-up).","labels":["phase4","quality","schema"],"dependencies":[{"issue_id":"openapi-generator-quq","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:02Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-99a","title":"[Q1] Method-name canonicalization","description":"Heuristic post-processor on snake-cased operationId: tokenize path template, drop trailing tokens that match path tokens (in reverse path order), drop trailing HTTP-method verb. Re-check uniqueness; restore tokens for collisions. Goal: Anthropic's betaGetFileMetadataV1FilesFileIdGet + path /v1/files/{fileId} + GET → get_file_metadata.\n\n## Context\nToday get_method_name emits op.operation_id.to_snake_case() verbatim. Anthropic's spec produces names like beta_get_file_metadata_v1_files_file_id_get — the path and HTTP method are literally appended into the operationId. See umbrella issue gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] Heuristic implemented in src/client_generator.rs:get_method_name (line ~859).\n- [ ] Unique across operation set; collisions fall back to original.\n- [ ] CLI/config flag [generator.method_names] strip_path = true (default true).\n- [ ] Snapshot tests confirm anthropic produces get_file_metadata not beta_get_file_metadata_v1_files_file_id_get.\n- [ ] All 49 currently-compiling specs still compile.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:10:47Z","created_by":"James Lal","updated_at":"2026-05-08T23:10:47Z","labels":["codegen","phase4","quality"],"dependencies":[{"issue_id":"openapi-generator-99a","depends_on_id":"openapi-generator-st8","type":"blocks","created_at":"2026-05-08T17:11:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-z4j","title":"Generated base64 codec helpers trigger dead_code warnings when unused","description":"Generated types.rs emits serialize/deserialize base64 codec helpers even when no field uses them, producing dead_code warnings in consuming crates (seen compiling anthropic.yml output). Add #[allow(dead_code)] or emit conditionally.","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-18T04:50:01Z","created_by":"James Lal","updated_at":"2026-07-18T04:50:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-3iv","title":"Print copyable cargo add commands for generated dependencies","description":"Render deterministic, copyable cargo add commands from the same DepRequirement collection used for REQUIRED_DEPS.toml, without modifying consumer manifests. Public issue: https://github.com/gpu-cli/openapi-to-rust/issues/31","acceptance_criteria":"Versions/features come from the existing collector; ordering is deterministic; with/without-feature cases have tests; normal CLI output shows copyable commands; quiet/JSON contracts stay stable; README documents fragment versus command.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T19:35:54Z","created_by":"James Lal","updated_at":"2026-07-15T19:35:54Z","external_ref":"gh-31","labels":["accessibility","dependencies","good-first-issue","help-wanted"],"dependencies":[{"issue_id":"openapi-generator-3iv","depends_on_id":"openapi-generator-1sb.7","type":"discovered-from","created_at":"2026-07-15T13:35:54Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-8om","title":"Add --quiet and --json output modes to validate","description":"Give the validate subcommand the same automation-friendly output modes as generate and init by reusing the existing configuration validation path and machine-readable error convention. Public issue: https://github.com/gpu-cli/openapi-to-rust/issues/30","acceptance_criteria":"validate --quiet is silent on success; validate --json emits exactly one JSON value on success and failure; invalid config exits nonzero; quiet/json conflict; CLI integration tests and README examples are updated.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T19:35:53Z","created_by":"James Lal","updated_at":"2026-07-15T19:35:53Z","external_ref":"gh-30","labels":["accessibility","cli","good-first-issue","help-wanted"],"dependencies":[{"issue_id":"openapi-generator-8om","depends_on_id":"openapi-generator-1sb.7","type":"discovered-from","created_at":"2026-07-15T13:35:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-0jz","title":"Server codegen: form-exploded object query params arrive as String","description":"Follow-up to GH #27 (openapi-generator-cv4). The client now types form-exploded object query params as structs, but server codegen (src/server/codegen.rs emit_query_struct/emit_method_sig) still keys off ParameterInfo.rust_type which stays String for these params, so handlers see a String they can never receive correctly. Proper fix needs serde(flatten) of the object struct into the per-op Query extractor struct (watch serde_urlencoded flatten limitations with non-string scalars).","design":"ParameterInfo.query_serialization is the shared client/server wire contract. Axum uses RawQuery plus an operation-specific decoder because Query\u003cT\u003e cannot preserve repeated keys or isolate exploded object namespaces. Supported flat object and scalar/string-enum array modes decode into owned generated types; ambiguous/undefined/nested shapes fail server generation. Generated clients and servers share a name[]= zero-cardinality marker so None, Some(empty), and missing required structured parameters remain distinct. explode=false values containing commas fail client serialization with guidance instead of silently corrupting boundaries.","acceptance_criteria":"Generated Axum extraction matches client serialization for required and optional objects, arrays, form explode true and false, and deepObject where defined; unsupported shapes fail or warn during generation; shared style analysis prevents drift; generated client and server round-trip proves typed values.","notes":"Acceptance verified. tests/server_query_roundtrip_test.rs generates a client and Axum server, starts a real TCP listener, and proves exact required/optional scalar, flat-object, deepObject, exploded/compact-array, component-array-alias, URL-escaped, and zero-cardinality values. Missing required structures return 400; comma-delimited loss cases return a client serialization error. Generation errors cover undefined deepObject, arrays of objects, nested objects, unions, additional ambiguity, exploded-key collisions, and deepObject collisions. cargo fmt --check, cargo clippy --all-features -- -D warnings, cargo test --all-features, cargo check --all-features, and git diff --check pass. url and serde_urlencoded requirement reporting is intentionally handled immediately next by blocked child openapi-generator-1sb.1.","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:23Z","created_by":"James Lal","updated_at":"2026-07-15T17:54:07Z","started_at":"2026-07-15T17:23:59Z","closed_at":"2026-07-15T17:54:07Z","close_reason":"Generated clients and Axum servers now share typed query serialization/extraction for every supported flat object and array mode, reject unsafe shapes, preserve empty/missing semantics, and pass live round-trip plus full regression gates.","labels":["adoption","query","server"],"dependencies":[{"issue_id":"openapi-generator-0jz","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-0jz","depends_on_id":"openapi-generator-1sb.3","type":"blocks","created_at":"2026-07-15T08:40:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 8d40798..a8c7640 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -166,7 +166,7 @@ fn crate_files(crate_name: &str, files: &[PlaygroundFile]) -> Vec { if (!output) return; - const tar = buildTar(output.crateName, output.crateFiles); - const gzipped = await new Response( - tar.stream().pipeThrough(new CompressionStream("gzip")), - ).blob(); - const url = URL.createObjectURL(gzipped); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${output.crateName}.tar.gz`; - anchor.click(); - URL.revokeObjectURL(url); + downloadButton.disabled = true; + downloadButton.textContent = "Preparing…"; + try { + const tar = buildTar(output.crateName, output.crateFiles); + const gzipped = await new Response( + tar.stream().pipeThrough(new CompressionStream("gzip")), + ).blob(); + const url = URL.createObjectURL(gzipped); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${output.crateName}.tar.gz`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + // Revoking immediately races the browser's fetch of the blob URL and + // can stall the download; give it a generous window instead. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } finally { + downloadButton.disabled = false; + downloadButton.textContent = "Download crate (.tar.gz)"; + } }); From cb96b6e533b2ad4d1f56c0ec61ae5e521d4789a5 Mon Sep 17 00:00:00 2001 From: James Lal Date: Fri, 17 Jul 2026 23:21:50 -0600 Subject: [PATCH 4/9] polish: playground + site UX fixes from impeccable critique loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two critique->fix rounds (score 25/40 -> 37/40, browser-verified): - Fix P0 mobile clipping: .playground and .doc-shell single-column grids used 1fr (min-content) with overflow:clip, making tabs/Copy/Download and docs body text unreachable at <=900px; now minmax(0,1fr) + pane min-width - Style runtime-created result tabs via is:global (scoped styles can't match JS-created elements); aria-pressed, 40px targets, hover state - Queue generation while WASM loads (was a silent no-op) with a status notice and auto-run on ready - Keep last good result visible (dimmed) when a generate fails; role=alert/ role=status on notices; result scrolls into view in single-column layout - types.rs tab first / REQUIRED_DEPS.toml last; pluralize stats; reset stale example-dropdown label on edit - Remove nested
on /playground; add WebApplication JSON-LD - Homepage secondary CTA now sells the playground - compressHTML:false — it stripped newlines before inline /, jamming words together on 6 pages; html-validate config drops the now-noisy trailing-whitespace rule - Type scale: doc-page h1 clamp 5.7->4.2rem (compare h1 was 84px and broke the product name), compact playground hero (Generate above the fold), all microtext >=12px, cap footer/verification-note line length - Docs "Last reviewed" + dateModified now opt-in per page (was a hardcoded fabricated default no page overrode) Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 1 + .../2026-07-18T05-06-10Z__website-src.md | 54 +++++++ .../2026-07-18T05-19-10Z__website-src.md | 33 ++++ .impeccable/critique/ignore.md | 10 ++ website/.htmlvalidate.json | 6 + website/astro.config.mjs | 4 + website/src/layouts/DocsLayout.astro | 19 ++- .../src/pages/compare/openapi-generator.astro | 5 +- website/src/pages/index.astro | 2 +- website/src/pages/playground.astro | 150 +++++++++++++----- website/src/styles/global.css | 16 +- 11 files changed, 244 insertions(+), 56 deletions(-) create mode 100644 .impeccable/critique/2026-07-18T05-06-10Z__website-src.md create mode 100644 .impeccable/critique/2026-07-18T05-19-10Z__website-src.md create mode 100644 .impeccable/critique/ignore.md create mode 100644 website/.htmlvalidate.json diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index eaad3c3..479a5fb 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -16,6 +16,7 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-789","title":"Impeccable critique+fix loop on website (playground + site UX/design, keep SEO)","description":"User asked for a loop: /impeccable critique then apply fixes across the Astro site at website/ (playground, homepage, docs, guides, compare). Goal: keep SEO focus, improve UX and design.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T04:54:16Z","created_by":"James Lal","updated_at":"2026-07-18T05:21:36Z","closed_at":"2026-07-18T05:21:36Z","close_reason":"Two dual-agent critique rounds completed. Score 25/40 -\u003e 37/40. Fixed: 2 P0 responsive clipping bugs (playground panes, doc-shell), unstyled runtime tabs, silent pre-WASM no-op, nested \u003cmain\u003e, error-destroys-result, tab order/pluralization/scroll-into-view, homepage playground CTA, oversized doc h1s, sub-12px microtext, fabricated review dates, compressHTML word-jamming (12+ instances), playground WebApplication JSON-LD. All fixes browser-verified; astro check, html-validate, SEO audit green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-l7v","title":"Artifact pipeline: playground-wasm.yml + fetch script + lock","description":"GH Action builds wasm on src/wasm changes, uploads release asset (GITHUB_TOKEN only), bumps website/playground-wasm.lock; website prebuild/predev fetch script pulls pinned asset when pkg/ absent. No Vercel token.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-18T01:40:44Z","created_by":"James Lal","updated_at":"2026-07-18T02:10:01Z","closed_at":"2026-07-18T02:10:01Z","close_reason":"Implemented and verified: wasm32 gate passes, playground runs locally with CLI-parity output, crate download compiles, pipeline dry-run green (bootstrap release + fetch build)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-26c","title":"Impeccable homepage and developer-copy critique","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:40:48Z","created_by":"James Lal","updated_at":"2026-07-16T04:48:45Z","started_at":"2026-07-16T04:41:10Z","closed_at":"2026-07-16T04:48:45Z","close_reason":"Completed dual-agent Impeccable critique with developer-copy focus; archived 30/40 baseline and prioritized trust, claim, copy, sequencing, and visual-system issues.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-1sb.10","title":"Replace quadratic operation-ID collision scan","description":"SchemaAnalyzer::ingest_path_item_operations canonicalizes and linearly scans every prior operation ID for each new operation. The 16k-operation Microsoft Graph corpus stays CPU-bound in this scan for many minutes. Maintain an analysis-wide canonical-ID set (including disambiguated candidates) so collision detection is O(1) average without changing emitted IDs or alias diagnostics.","acceptance_criteria":"Existing duplicate/case-collision operation-ID tests remain identical; add a large synthetic regression that verifies deterministic IDs without quadratic scanning; the Microsoft Graph generation portion of scripts/spec-compile.sh completes materially faster; full suite passes.","notes":"Replaced the per-operation full BTreeMap canonicalization scan with one analysis-wide HashSet shared across paths and webhooks. Added a 2,003-operation deterministic collision/alias regression (Foo.bar, foo_bar_get, foo_bar_get_2), which completes in 0.05s. Microsoft Graph exposed two additional request-model generation rescans; precomputed request-body roots and reserved Rust names once per types.rs pass without changing output. Microsoft Graph generation now completes in ~30s versus the original run remaining CPU-bound after ~20 minutes. Full scripts/spec-compile.sh result: 54 passed, 0 gen-failed, 0 check-failed, 1 expected Swagger 2.0 skip. Full fmt/check/clippy/docs/tests pass.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T17:03:41Z","created_by":"James Lal","updated_at":"2026-07-15T17:22:43Z","started_at":"2026-07-15T17:10:33Z","closed_at":"2026-07-15T17:22:43Z","close_reason":"Canonical operation IDs and type-generation lookups are indexed once; large regression, Microsoft Graph, full corpus, and all quality gates pass.","labels":["accessibility","adoption","analysis","performance"],"dependencies":[{"issue_id":"openapi-generator-1sb.10","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T11:03:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.10","depends_on_id":"openapi-generator-st8","type":"discovered-from","created_at":"2026-07-15T11:03:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.impeccable/critique/2026-07-18T05-06-10Z__website-src.md b/.impeccable/critique/2026-07-18T05-06-10Z__website-src.md new file mode 100644 index 0000000..62824e8 --- /dev/null +++ b/.impeccable/critique/2026-07-18T05-06-10Z__website-src.md @@ -0,0 +1,54 @@ +--- +target: website/src (playground + site) +total_score: 25 +p0_count: 2 +p1_count: 3 +timestamp: 2026-07-18T05-06-10Z +slug: website-src +--- +Method: dual-agent (A: design review sub-agent · B: detector/browser sub-agent) + +# Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 2 | Generate/example pick before WASM ready is a silent no-op; stale example dropdown label | +| 2 | Match System / Real World | 3 | YAML parser jargon in errors; otherwise excellent domain copy | +| 3 | User Control and Freedom | 2 | Failed generate destroys previous result; example pick clobbers pasted spec | +| 4 | Consistency and Standards | 2 | Unstyled UA-default tab buttons; nested
; tablist roles without keyboard pattern | +| 5 | Error Prevention | 2 | No guard on clobbering user spec; pre-ready clicks eaten | +| 6 | Recognition Rather Than Recall | 3 | REQUIRED_DEPS.toml leads tab row; otherwise good hints | +| 7 | Flexibility and Efficiency | 3 | Cmd+Enter, URL fetch, crate download; no permalink | +| 8 | Aesthetic and Minimalist Design | 3 | 6rem "Playground" h1 pushes Generate below the fold at 900px | +| 9 | Error Recovery | 2 | line/col given but no editor line numbers; no aria-live | +| 10 | Help and Documentation | 3 | Strong footer + docs cross-links | +| **Total** | | **25/40** | **Acceptable** | + +# Anti-Patterns Verdict +Not slop overall: varied composition, evidence-driven copy, coherent print-terminal register. Static detector: 0 findings. In-page detector: 13 findings across 5 pages — hero-eyebrow-chip on 4/5 pages, tiny-text 11.52px on 3 pages, cramped .table-wrap padding on 2, oversized 84px compare h1, long line-length on /playground (~160ch) and /compare (~110ch). The one "looks broken/AI" moment: unstyled native tab buttons on the playground result. + +# Priority Issues +- [P0] Playground output pane clipped ≤900px: tabs/Copy/Download unreachable; `.playground { grid-template-columns: 1fr }` at ≤900px loses minmax(0,…) + `main{overflow:clip}` makes it unscrollable (playground.astro:575, global.css:65). +- [P0] Docs-layout pages truncate text at 390px: `.doc-shell { grid-template-columns: 1fr }` (global.css:508) needs minmax(0,1fr); verified live. +- [P1] Result tabs unstyled: JS-created buttons never get data-astro-cid-*, scoped .tab-row rules can't match (playground.astro:212, 528-543). Active tab indistinguishable, ~24px targets. +- [P1] Silent no-op before worker ready (generate() early-return, playground.astro:243); flagship example path fills textarea then nothing happens. +- [P1] Nested
landmarks (BaseLayout.astro:64 + playground.astro:19). +- [P2] Homepage never sells the playground; hero CTAs go to docs + anchor only. +- [P2] Errors invisible to AT (no aria-live/role=alert) and destroy prior result (playground.astro:74-75, 233-240). +- [P2] Tablist semantics without keyboard behavior; REQUIRED_DEPS.toml first; "1 schemas" pluralization (playground.astro:81, 210-226). +- [P2] Compare h1 84px breaks product name across lines; page-hero h1 clamp oversized (global.css:338). +- [P2] Tiny text 11.52px (0.72rem figcaption/footer labels) on 3 pages; doc-pagination 0.66rem. +- [P3] No JSON-LD on /playground; shared generic og-card. Stale example dropdown label. DocsLayout hardcoded updated-date default. Avenir-only personality on macOS. + +# Persona Red Flags +- Alex: Cmd+Enter swallowed during load → "it's broken" in 5s; role=tab promises arrow keys that don't exist; no shareable permalink. +- Jordan: REQUIRED_DEPS.toml + "2 schemas · 4 operations" vocabulary; parser-speak errors with no line numbers; example pick destroys pasted work. +- Casey: mobile playground generates but result is clipped + no scroll-into-view — can't copy/download; docs pages unreadable at 390. + +# Minor Observations +Warning palette untokenized; playground-footer inline-code wraps oddly; 404 page is great; a11y baseline (skip-link, focus-visible, reduced-motion) above average; dev-toolbar 504 is a dev-only artifact. + +# Questions +- Should the playground be the homepage hero's primary demo ("run it on your spec now, nothing uploaded")? +- Could the error state keep the last good result visible instead of a 480px void? +- Is "Playground" worth 230px of h1, or should the tool start at the top? diff --git a/.impeccable/critique/2026-07-18T05-19-10Z__website-src.md b/.impeccable/critique/2026-07-18T05-19-10Z__website-src.md new file mode 100644 index 0000000..a6bb936 --- /dev/null +++ b/.impeccable/critique/2026-07-18T05-19-10Z__website-src.md @@ -0,0 +1,33 @@ +--- +target: website/src (playground + site) +total_score: 37 +p0_count: 0 +p1_count: 1 +timestamp: 2026-07-18T05-19-10Z +slug: website-src +--- +Method: dual-agent (A: design review sub-agent · B: detector/browser sub-agent) — round 2, post-fix verification + +# Design Health Score +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 4 | Loading notice, stale dim, auto-scroll — exemplary | +| 2 | Match System / Real World | 4 | | +| 3 | User Control and Freedom | 3 | No clear/reset affordance | +| 4 | Consistency and Standards | 3 | Site-wide compressHTML missing-space text bug | +| 5 | Error Prevention | 3 | | +| 6 | Recognition Rather Than Recall | 4 | | +| 7 | Flexibility and Efficiency | 4 | | +| 8 | Aesthetic and Minimalist Design | 4 | | +| 9 | Error Recovery | 4 | Prev output kept dimmed; exact parse errors | +| 10 | Help and Documentation | 4 | | +| **Total** | | **37/40** | **Excellent** | + +# Verification +All 11 round-1 fixes VERIFIED live (incl. pre-WASM-ready notice under 6× CPU throttle). Zero horizontal overflow at 390/768. Detector: static scan clean; oversized-h1 fixed; tiny-text down to 1 element (#comparison-scroll-hint 11.84px); line-length on .playground-footer (~160ch) and .verification-note (~110ch); table-wrap + eyebrow findings accepted in ignore.md. + +# Priority Issues +- [P1] compressHTML strips newlines before inline / → words jam together ("theschema support notes"); 12+ instances / 6 pages. Fix: compressHTML: false in astro.config.mjs. +- [P3] Compare h1 hyphen-breaks "openapi-to-rust" at 390 → nowrap span. +- [P3] Screen-reader announcement of playground states (role=alert/status present in source — verify implicit live regions suffice). +- [P3] scroll-hint 11.84px; playground-footer + verification-note line length. diff --git a/.impeccable/critique/ignore.md b/.impeccable/critique/ignore.md new file mode 100644 index 0000000..d23440c --- /dev/null +++ b/.impeccable/critique/ignore.md @@ -0,0 +1,10 @@ +# Accepted / false-positive detector findings + +- **hero-eyebrow-chip** (all pages): the mono kicker above page-hero h1s is a deliberate, + named brand system used exactly once per page (category label above the h1 in + DocsLayout, `.hero-kicker` on the homepage). It is not the per-section eyebrow + scaffold the rule targets. Re-flag only if eyebrows start appearing above body + sections. +- **cramped-padding on `.table-wrap`** (/docs/getting-started, /compare/openapi-generator): + a table inside a horizontal-scroll container is conventionally flush with the + container edges; cells carry their own 0.9rem/1rem padding. Judged intentional. diff --git a/website/.htmlvalidate.json b/website/.htmlvalidate.json new file mode 100644 index 0000000..2f7541d --- /dev/null +++ b/website/.htmlvalidate.json @@ -0,0 +1,6 @@ +{ + "extends": ["html-validate:recommended"], + "rules": { + "no-trailing-whitespace": "off" + } +} diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 2506be0..ebc7256 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -4,6 +4,10 @@ import sitemap from "@astrojs/sitemap"; export default defineConfig({ site: "https://openapi-to-rust.dev", output: "static", + // compressHTML strips the newline between a text line and a following inline + // /, jamming words together ("…review theschema notes"). Gzip makes + // the size difference negligible; correctness wins. + compressHTML: false, trailingSlash: "never", integrations: [sitemap()], build: { diff --git a/website/src/layouts/DocsLayout.astro b/website/src/layouts/DocsLayout.astro index a4b75e5..49c38e6 100644 --- a/website/src/layouts/DocsLayout.astro +++ b/website/src/layouts/DocsLayout.astro @@ -15,6 +15,7 @@ interface Props { canonicalPath: string; category?: string; toc?: TocItem[]; + /** ISO date (YYYY-MM-DD) of the last substantive review; omit when unknown. */ updated?: string; } @@ -26,7 +27,7 @@ const { canonicalPath, category = "Documentation", toc = [], - updated = "July 15, 2026", + updated, } = Astro.props; const absoluteUrl = new URL(canonicalPath, Astro.site).toString(); @@ -37,13 +38,21 @@ const breadcrumbItems = [ : []), { "@type": "ListItem", position: category === "Documentation" ? 3 : 2, name: h1, item: absoluteUrl }, ]; +const updatedDisplay = updated + ? new Date(`${updated}T00:00:00Z`).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }) + : undefined; const schema = [ { "@context": "https://schema.org", "@type": "TechArticle", headline: h1, description, - dateModified: "2026-07-15", + ...(updated ? { dateModified: updated } : {}), url: absoluteUrl, author: { "@type": "Organization", @@ -78,9 +87,9 @@ const next = currentIndex >= 0 && currentIndex < docsOrder.length - 1 ? docsOrde

{category}

-

{h1}

+

{h1}

{lead}

-

Last reviewed {updated}

+ {updatedDisplay &&

Last reviewed {updatedDisplay}

}
{toc.length > 0 && ( @@ -112,5 +121,5 @@ const next = currentIndex >= 0 && currentIndex < docsOrder.length - 1 ? docsOrde diff --git a/website/src/pages/compare/openapi-generator.astro b/website/src/pages/compare/openapi-generator.astro index 6068b06..56c64f1 100644 --- a/website/src/pages/compare/openapi-generator.astro +++ b/website/src/pages/compare/openapi-generator.astro @@ -22,6 +22,7 @@ const toc = [ category="Comparison" {toc} > + openapi-to-rust vs OpenAPI Generator for Rust
Independent project

openapi-to-rust is not affiliated with OpenAPI Generator, OpenAPI Tools, or the OpenAPI Initiative. OpenAPI Generator is a trademark or project name of its respective maintainers.

@@ -152,12 +153,12 @@ openapi-generator-cli generate \\ .comparison-table table { min-width: 920px; } .comparison-table th:first-child { position: sticky; z-index: 1; left: 0; box-shadow: 1px 0 0 var(--line); } .comparison-table thead th:first-child { z-index: 2; } - .scroll-hint { margin-bottom: -1.25rem; color: var(--muted); font-family: var(--font-mono); font-size: 0.74rem; } + .scroll-hint { margin-bottom: -1.25rem; color: var(--muted); font-family: var(--font-mono); font-size: 0.78rem; } .comparison-commands { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .decision-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-block: 2rem; } .decision-grid > div { border: 1px solid var(--ink); border-radius: 5px; background: var(--white); padding: 1.3rem; } .decision-grid h3 { margin-top: 0; font-size: 1.25rem; } .decision-grid ul { margin-top: 1rem; } - .verification-note { border-top: 1px solid var(--line); padding-top: 1rem; font-family: var(--font-mono); font-size: 0.76rem; } + .verification-note { max-width: var(--reading); border-top: 1px solid var(--line); padding-top: 1rem; font-family: var(--font-mono); font-size: 0.76rem; } @media (max-width: 720px) { .comparison-commands, .decision-grid { grid-template-columns: 1fr; } } diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index f25f5f9..3b81635 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -95,7 +95,7 @@ const schemas = [

CI generates all 54 specs on every pull request; OpenAI and Anthropic outputs also compile.

v0.7.0 · Rust 1.88+ · MIT licensed

diff --git a/website/src/pages/playground.astro b/website/src/pages/playground.astro index 2140f76..3ae7af9 100644 --- a/website/src/pages/playground.astro +++ b/website/src/pages/playground.astro @@ -9,14 +9,29 @@ const examples = [ const anthropicSpecUrl = "https://raw.githubusercontent.com/gpu-cli/openapi-to-rust/main/tests/fixtures/anthropic.yml"; + +const description = + "Paste an OpenAPI 3.0/3.1 document and instantly see the typed Rust models and clients openapi-to-rust generates — running entirely in your browser via WebAssembly."; + +const schema = { + "@context": "https://schema.org", + "@type": "WebApplication", + name: "openapi-to-rust playground", + url: "https://openapi-to-rust.dev/playground", + description, + applicationCategory: "DeveloperApplication", + operatingSystem: "Any (browser, WebAssembly)", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + isPartOf: { "@type": "WebSite", name: "openapi-to-rust", url: "https://openapi-to-rust.dev/" }, +}; --- -

Playground

@@ -53,7 +68,7 @@ const anthropicSpecUrl =
- +