Repository: bytecodealliance/wasm-tools
Pull request #1597 (2024-06-08 22:02:28)

proof-of-concept playground
Fixes https://github.com/bytecodealliance/wasm-tools/issues/1595.

You can try [print playground](https://bakkot-org.github.io/wasm-tools/print) and the [parse playground](https://bakkot-org.github.io/wasm-tools/parse).

It's extremely basic and not very pretty, but it does work. I've only implemented `wasm-tools parse` ~~so far because I want to give maintainers a chance to weigh in on the approach before doing more work. I'm happy to implement another for `wasm-tools print` as well, and maybe a third for `dump`+`objdump`+`metadata show` if you think that's worth doing.~~ EDIT: and the `wasm-tools print` one.

I'm using `cargo-component` and `jco`, mostly because I thought that would be more fun than `wasm-bindgen`. Unfortunately I did need to target `wasm32-wasi` since I get an `unreachable` when targeting `wasm32-unknown-unknown`. And there does not seem to be a simple way to provide my own shims of the preview-1 functions when using `cargo component`; it unconditionally translates them to preview-2. But that's fine because `jco` has built in support for shimming preview-2. This adds about 50Kb of (unminimized) JavaScript, but whatever.

This includes a new workflow which a) ensures the playground builds in every PR and b) uploads the built artifact to gh-pages on every commit to `main`. I think you'll need to manually enable GitHub pages and set the source to "GitHub Actions" for this to actually do anything, but it's possible the included actions will take care of that.

I've commented out the main CI for the sake of not taking up a bunch of resources while iterating on this. I'll revert that before marking this as ready, of course.


bakkot: I'll go ahead and make the `print` page as well, then, and add a root `index.html` which I guess just redirects to the github project (unless you want to have a separate page listing the tools, but that seems a bit redundant). Then I think think it should be fit to land.
bakkot: Alright, added the [printer](https://bakkot.github.io/wasm-tools/print) as well. This is good to go from my end once the CI thread above is resolved. Which I'm happy to do but if you'd find it easier to make the changes yourself rather than explaining them that's also fine.

Patch:
diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml
new file mode 100644
index 00000000..c6472c16
--- /dev/null
+++ b/.github/workflows/playground.yml
@@ -0,0 +1,59 @@
+name: Build playground
+on:
+  push:
+    branches: [main]
+  pull_request:
+  merge_group:
+
+# Cancel any in-flight jobs for the same PR/branch so there's only one active
+# at a time
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+defaults:
+  run:
+    shell: bash
+
+jobs:
+  build:
+    name: Build
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          submodules: true
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - uses: bytecodealliance/wasmtime/.github/actions/install-rust@v20.0.0
+      - uses: cargo-bins/cargo-binstall@v1.6.9
+      - run: cargo binstall cargo-component -y
+      - run: rustup component add rustfmt # needed for cargo-component, apparently?
+      - run: npm ci
+        working-directory: playground
+      - run: npm run build
+        working-directory: playground
+
+      # also prepare to deploy GH pages on main
+      - if: github.ref == 'refs/heads/main'
+        uses: actions/configure-pages@v5
+      - if: github.ref == 'refs/heads/main'
+        uses: actions/upload-pages-artifact@v3
+        with:
+          path: "./playground/dist"
+
+  deploy:
+    name: Deploy
+    if: github.ref == 'refs/heads/main'
+    needs: build
+    permissions:
+      pages: write
+      id-token: write
+    runs-on: ubuntu-latest
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - id: deployment
+        uses: actions/deploy-pages@v4
diff --git a/Cargo.lock b/Cargo.lock
index 7bf57c07..b2b6b81c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -285,6 +285,15 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
 
+[[package]]
+name = "component"
+version = "0.0.0"
+dependencies = [
+ "wasmprinter 0.209.1",
+ "wat",
+ "wit-bindgen-rt",
+]
+
 [[package]]
 name = "constant_time_eq"
 version = "0.3.0"
@@ -2095,6 +2104,15 @@ version = "0.52.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
 
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29c7526379ace8709ee9ab9f2bb50f112d95581063a59ef3097d9c10153886c9"
+dependencies = [
+ "bitflags",
+]
+
 [[package]]
 name = "wit-component"
 version = "0.209.1"
diff --git a/Cargo.toml b/Cargo.toml
index 4bf41c45..141c5bc4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,7 @@ members = [
   'fuzz',
   'crates/wit-parser/fuzz',
   'crates/wit-component/dl',
+  'playground/component',
 ]
 
 [workspace.lints.rust]
diff --git a/crates/wasmparser/src/binary_reader.rs b/crates/wasmparser/src/binary_reader.rs
index 99dfca38..4df00290 100644
--- a/crates/wasmparser/src/binary_reader.rs
+++ b/crates/wasmparser/src/binary_reader.rs
@@ -732,14 +732,27 @@ impl<'a> BinaryReader<'a> {
     pub(crate) fn read_block_type(&mut self) -> Result<BlockType> {
         let b = self.peek()?;
 
-        // Check for empty block
-        if b == 0x40 {
-            self.position += 1;
-            return Ok(BlockType::Empty);
-        }
-
-        // Check for a block type of form [] -> [t].
-        if ValType::is_valtype_byte(b) {
+        // Block types are encoded as either 0x40, a `valtype`, or `s33`. All
+        // current `valtype` encodings are negative numbers when encoded with
+        // sleb128, but it's also required that valtype encodings are in their
+        // canonical form. For example an overlong encoding of -1 as `0xff 0x7f`
+        // is not valid and it is required to be `0x7f`. This means that we
+        // can't simply match on the `s33` that pops out below since reading the
+        // whole `s33` might read an overlong encoding.
+        //
+        // To test for this the first byte `b` is inspected. The highest bit,
+        // the continuation bit in LEB128 encoding, must be clear. The next bit,
+        // the sign bit, must be set to indicate that the number is negative. If
+        // these two conditions hold then we're guaranteed that this is a
+        // negative number.
+        //
+        // After this a value type is read directly instead of looking for an
+        // indexed value type.
+        if b & 0x80 == 0 && b & 0x40 != 0 {
+            if b == 0x40 {
+                self.position += 1;
+                return Ok(BlockType::Empty);
+            }
             return Ok(BlockType::Type(self.read()?));
         }
 
diff --git a/crates/wasmparser/src/readers/core/types.rs b/crates/wasmparser/src/readers/core/types.rs
index 3297a269..8df5c03d 100644
--- a/crates/wasmparser/src/readers/core/types.rs
+++ b/crates/wasmparser/src/readers/core/types.rs
@@ -1369,18 +1369,10 @@ pub enum HeapType {
     NoExn,
 }
 
-impl ValType {
-    pub(crate) fn is_valtype_byte(byte: u8) -> bool {
-        match byte {
-            0x7F | 0x7E | 0x7D | 0x7C | 0x7B | 0x70 | 0x6F | 0x64 | 0x63 | 0x6E | 0x71 | 0x72
-            | 0x74 | 0x73 | 0x6D | 0x6B | 0x6A | 0x6C | 0x69 => true,
-            _ => false,
-        }
-    }
-}
-
 impl<'a> FromReader<'a> for StorageType {
     fn from_reader(reader: &mut BinaryReader<'a>) -> Result<Self> {
+        // NB: See `FromReader<'a> for ValType` for a table of how this
+        // interacts with other value encodings.
         match reader.peek()? {
             0x78 => {
                 reader.read_u8()?;
@@ -1397,6 +1389,53 @@ impl<'a> FromReader<'a> for StorageType {
 
 impl<'a> FromReader<'a> for ValType {
     fn from_reader(reader: &mut BinaryReader<'a>) -> Result<Self> {
+        // Decoding value types is sort of subtle because the space of what's
+        // being decoded here is actually spread out across an number of
+        // locations. This comment here is intended to serve as a bit of a
+        // reference to what's being decoded here and how it interacts with
+        // other locations.
+        //
+        // Note that all value types are encoded as canonical-form negative
+        // numbers in the sleb128 encoding scheme. Currently in the wasm spec
+        // sleb128 isn't actually used but it looks to be modelled to allow it
+        // one day. In the meantime the current values used are:
+        //
+        // | sleb128 | decimal | type         | notes                        |
+        // |---------|---------|--------------|------------------------------|
+        // | 0x7F    | -1      | i32          |                              |
+        // | 0x7E    | -2      | i64          |                              |
+        // | 0x7D    | -3      | f32          |                              |
+        // | 0x7C    | -4      | f64          |                              |
+        // | 0x7B    | -5      | v128         | simd proposal                |
+        // | 0x78    | -8      | i8           | gc proposal, in `FieldType`  |
+        // | 0x77    | -9      | i16          | gc proposal, in `FieldType`  |
+        // | 0x74    | -12     | noexn        | gc + exceptions proposal     |
+        // | 0x73    | -13     | nofunc       | gc proposal                  |
+        // | 0x72    | -14     | noextern     | gc proposal                  |
+        // | 0x71    | -15     | nullref      | gc proposal                  |
+        // | 0x70    | -16     | func         | reference types proposal     |
+        // | 0x6F    | -17     | extern       | reference types proposal     |
+        // | 0x6E    | -18     | any          | gc proposal                  |
+        // | 0x6D    | -19     | eq           | gc proposal                  |
+        // | 0x6C    | -20     | i31          | gc proposal                  |
+        // | 0x6B    | -21     | struct       | gc proposal                  |
+        // | 0x6A    | -22     | array        | gc proposal                  |
+        // | 0x69    | -23     | exnref       | gc + exceptions proposal     |
+        // | 0x64    | -28     | ref $t       | gc proposal, prefix byte     |
+        // | 0x63    | -29     | ref null $t  | gc proposal, prefix byte     |
+        // | 0x60    | -32     | func $t      | prefix byte                  |
+        // | 0x5f    | -33     | struct $t    | gc proposal, prefix byte     |
+        // | 0x5e    | -34     | array $t     | gc proposal, prefix byte     |
+        // | 0x50    | -48     | sub $t       | gc proposal, prefix byte     |
+        // | 0x4F    | -49     | sub final $t | gc proposal, prefix byte     |
+        // | 0x4E    | -50     | rec $t       | gc proposal, prefix byte     |
+        // | 0x40    | -64     | ε            | empty block type             |
+        //
+        // Note that not all of these encodings are parsed here, for example
+        // 0x78 as the encoding for `i8` is parsed only in `FieldType`. The
+        // parsing of `FieldType` will delegate here without actually consuming
+        // anything though so the encoding 0x78 still must be disjoint and not
+        // read here otherwise.
         match reader.peek()? {
             0x7F => {
                 reader.read_u8()?;
@@ -1427,25 +1466,27 @@ impl<'a> FromReader<'a> for ValType {
 
 impl<'a> FromReader<'a> for RefType {
     fn from_reader(reader: &mut BinaryReader<'a>) -> Result<Self> {
+        // NB: See `FromReader<'a> for ValType` for a table of how this
+        // interacts with other value encodings.
         match reader.read()? {
-            0x70 => Ok(RefType::FUNC.nullable()),
-            0x6F => Ok(RefType::EXTERN.nullable()),
-            0x6E => Ok(RefType::ANY.nullable()),
-            0x71 => Ok(RefType::NONE.nullable()),
-            0x72 => Ok(RefType::NOEXTERN.nullable()),
-            0x73 => Ok(RefType::NOFUNC.nullable()),
-            0x6D => Ok(RefType::EQ.nullable()),
-            0x6B => Ok(RefType::STRUCT.nullable()),
-            0x6A => Ok(RefType::ARRAY.nullable()),
-            0x6C => Ok(RefType::I31.nullable()),
-            0x69 => Ok(RefType::EXN.nullable()),
-            0x74 => Ok(RefType::NOEXN.nullable()),
             byte @ (0x63 | 0x64) => {
                 let nullable = byte == 0x63;
                 let pos = reader.original_position();
                 RefType::new(nullable, reader.read()?)
                     .ok_or_else(|| crate::BinaryReaderError::new("type index too large", pos))
             }
+            0x69 => Ok(RefType::EXN.nullable()),
+            0x6A => Ok(RefType::ARRAY.nullable()),
+            0x6B => Ok(RefType::STRUCT.nullable()),
+            0x6C => Ok(RefType::I31.nullable()),
+            0x6D => Ok(RefType::EQ.nullable()),
+            0x6E => Ok(RefType::ANY.nullable()),
+            0x6F => Ok(RefType::EXTERN.nullable()),
+            0x70 => Ok(RefType::FUNC.nullable()),
+            0x71 => Ok(RefType::NONE.nullable()),
+            0x72 => Ok(RefType::NOEXTERN.nullable()),
+            0x73 => Ok(RefType::NOFUNC.nullable()),
+            0x74 => Ok(RefType::NOEXN.nullable()),
             _ => bail!(reader.original_position(), "malformed reference type"),
         }
     }
@@ -1453,6 +1494,8 @@ impl<'a> FromReader<'a> for RefType {
 
 impl<'a> FromReader<'a> for HeapType {
     fn from_reader(reader: &mut BinaryReader<'a>) -> Result<Self> {
+        // NB: See `FromReader<'a> for ValType` for a table of how this
+        // interacts with other value encodings.
         match reader.peek()? {
             0x70 => {
                 reader.read_u8()?;
@@ -1669,6 +1712,8 @@ fn read_composite_type(
     opcode: u8,
     reader: &mut BinaryReader,
 ) -> Result<CompositeType, BinaryReaderError> {
+    // NB: See `FromReader<'a> for ValType` for a table of how this
+    // interacts with other value encodings.
     Ok(match opcode {
         0x60 => CompositeType::Func(reader.read()?),
         0x5e => CompositeType::Array(reader.read()?),
@@ -1679,6 +1724,8 @@ fn read_composite_type(
 
 impl<'a> FromReader<'a> for RecGroup {
     fn from_reader(reader: &mut BinaryReader<'a>) -> Result<Self> {
+        // NB: See `FromReader<'a> for ValType` for a table of how this
+        // interacts with other value encodings.
         match reader.peek()? {
             0x4e => {
                 reader.read_u8()?;
@@ -1702,6 +1749,8 @@ impl<'a> FromReader<'a> for RecGroup {
 impl<'a> FromReader<'a> for SubType {
     fn from_reader(reader: &mut BinaryReader<'a>) -> Result<Self> {
         let pos = reader.original_position();
+        // NB: See `FromReader<'a> for ValType` for a table of how this
+        // interacts with other value encodings.
         Ok(match reader.read_u8()? {
             opcode @ (0x4f | 0x50) => {
                 let idx_iter = reader.read_iter(MAX_WASM_SUPERTYPES, "supertype idxs")?;
diff --git a/crates/wit-parser/src/lib.rs b/crates/wit-parser/src/lib.rs
index e227f7fc..72e0857b 100644
--- a/crates/wit-parser/src/lib.rs
+++ b/crates/wit-parser/src/lib.rs
@@ -827,7 +827,7 @@ impl Function {
 /// annotations were added to WIT.
 #[derive(Debug, Clone, PartialEq, Eq)]
 #[cfg_attr(feature = "serde", derive(serde_derive::Deserialize, Serialize))]
-#[cfg_attr(feature = "serde", serde(rename_all = "lowercase", tag = "type"))]
+#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
 pub enum Stability {
     /// `@since(version = 1.2.3)`
     ///
diff --git a/crates/wit-parser/src/resolve.rs b/crates/wit-parser/src/resolve.rs
index 8a34a8e6..a41290a6 100644
--- a/crates/wit-parser/src/resolve.rs
+++ b/crates/wit-parser/src/resolve.rs
@@ -74,6 +74,10 @@ pub struct Resolve {
     /// set.
     #[cfg_attr(feature = "serde", serde(skip))]
     pub features: IndexSet<String>,
+
+    /// Activate all features for this [`Resolve`].
+    #[cfg_attr(feature = "serde", serde(skip))]
+    pub all_features: bool,
 }
 
 /// A WIT package within a `Resolve`.
@@ -1142,7 +1146,7 @@ impl Resolve {
     fn include_stability(&self, stability: &Stability) -> bool {
         match stability {
             Stability::Stable { .. } | Stability::Unknown => true,
-            Stability::Unstable { feature } => self.features.contains(feature),
+            Stability::Unstable { feature } => self.features.contains(feature) || self.all_features,
         }
     }
 }
diff --git a/playground/.gitignore b/playground/.gitignore
new file mode 100644
index 00000000..06af0ad9
--- /dev/null
+++ b/playground/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+component-built/
+dist/
diff --git a/playground/README.md b/playground/README.md
new file mode 100644
index 00000000..6bc44d34
--- /dev/null
+++ b/playground/README.md
@@ -0,0 +1,30 @@
+# Playground
+
+This is a simple online playground for `wasm-tools parse`, available at https://bytecodealliance.gthub.io/wasm-tools/.
+
+## Building
+
+In addition to `cargo`, you'll need to have `node` and [`cargo-component`](https://github.com/bytecodealliance/cargo-Component) installed and on your path.
+
+Then
+
+```sh
+npm ci
+```
+to install dependencies and
+```sh
+npm run build
+```
+to build.
+
+Output will be placed under `dist`.
+
+## Making changes
+
+The [./component/wit/world.wit](./component/wit/world.wit) file defines the exported functions for use by the playground. After changing it you'll need to `npm run build` to regenerate the bindings file (the command will fail but still generate the bindings) and then implement the functions in [./component/src/lib.rs](./component/src/lib.rs).
+
+After `npm run build`ing again you can make use of those functions from [./src/worker.ts](./src/worker.ts), with full type safety (thanks to [`jco`](https://github.com/bytecodealliance/jco)).
+
+It is also possible to use them outside of a worker, but since they run synchronously and block their thread the playground is designed to do all calls off the main thread.
+
+To preview your changes after building you'll need to run a web server; the worker will not load from a `file:` URL. The simplest way to do this is by running `npx http-server dist`, but you can use whatever means you'd like.
diff --git a/playground/component/.gitignore b/playground/component/.gitignore
new file mode 100644
index 00000000..2f7896d1
--- /dev/null
+++ b/playground/component/.gitignore
@@ -0,0 +1,1 @@
+target/
diff --git a/playground/component/Cargo.toml b/playground/component/Cargo.toml
new file mode 100644
index 00000000..4a3cd14a
--- /dev/null
+++ b/playground/component/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "component"
+publish = false
+edition.workspace = true
+
+[dependencies]
+wit-bindgen-rt = { version = "0.26.0", features = ["bitflags"] }
+wat = { workspace = true }
+wasmprinter = { workspace = true }
+
+[lib]
+crate-type = ["cdylib"]
+
+[profile.release]
+codegen-units = 1
+opt-level = "s"
+debug = false
+strip = true
+lto = true
+
+[package.metadata.component]
+package = "component:component"
+
+[package.metadata.component.dependencies]
diff --git a/playground/component/src/bindings.rs b/playground/component/src/bindings.rs
new file mode 100644
index 00000000..30fe002d
--- /dev/null
+++ b/playground/component/src/bindings.rs
@@ -0,0 +1,306 @@
+// Generated by `wit-bindgen` 0.25.0. DO NOT EDIT!
+// Options used:
+#[derive(Clone)]
+pub enum PrintPart {
+    Str(_rt::String),
+    Name,
+    Literal,
+    Keyword,
+    Type,
+    Comment,
+    Reset,
+}
+impl ::core::fmt::Debug for PrintPart {
+    fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
+        match self {
+            PrintPart::Str(e) => f.debug_tuple("PrintPart::Str").field(e).finish(),
+            PrintPart::Name => f.debug_tuple("PrintPart::Name").finish(),
+            PrintPart::Literal => f.debug_tuple("PrintPart::Literal").finish(),
+            PrintPart::Keyword => f.debug_tuple("PrintPart::Keyword").finish(),
+            PrintPart::Type => f.debug_tuple("PrintPart::Type").finish(),
+            PrintPart::Comment => f.debug_tuple("PrintPart::Comment").finish(),
+            PrintPart::Reset => f.debug_tuple("PrintPart::Reset").finish(),
+        }
+    }
+}
+#[doc(hidden)]
+#[allow(non_snake_case)]
+pub unsafe fn _export_parse_cabi<T: Guest>(arg0: *mut u8, arg1: usize) -> *mut u8 {
+    #[cfg(target_arch = "wasm32")]
+    _rt::run_ctors_once();
+    let len0 = arg1;
+    let bytes0 = _rt::Vec::from_raw_parts(arg0.cast(), len0, len0);
+    let result1 = T::parse(_rt::string_lift(bytes0));
+    let ptr2 = _RET_AREA.0.as_mut_ptr().cast::<u8>();
+    match result1 {
+        Ok(e) => {
+            *ptr2.add(0).cast::<u8>() = (0i32) as u8;
+            let vec3 = (e).into_boxed_slice();
+            let ptr3 = vec3.as_ptr().cast::<u8>();
+            let len3 = vec3.len();
+            ::core::mem::forget(vec3);
+            *ptr2.add(8).cast::<usize>() = len3;
+            *ptr2.add(4).cast::<*mut u8>() = ptr3.cast_mut();
+        }
+        Err(e) => {
+            *ptr2.add(0).cast::<u8>() = (1i32) as u8;
+            let vec4 = (e.into_bytes()).into_boxed_slice();
+            let ptr4 = vec4.as_ptr().cast::<u8>();
+            let len4 = vec4.len();
+            ::core::mem::forget(vec4);
+            *ptr2.add(8).cast::<usize>() = len4;
+            *ptr2.add(4).cast::<*mut u8>() = ptr4.cast_mut();
+        }
+    };
+    ptr2
+}
+#[doc(hidden)]
+#[allow(non_snake_case)]
+pub unsafe fn __post_return_parse<T: Guest>(arg0: *mut u8) {
+    let l0 = i32::from(*arg0.add(0).cast::<u8>());
+    match l0 {
+        0 => {
+            let l1 = *arg0.add(4).cast::<*mut u8>();
+            let l2 = *arg0.add(8).cast::<usize>();
+            let base3 = l1;
+            let len3 = l2;
+            _rt::cabi_dealloc(base3, len3 * 1, 1);
+        }
+        _ => {
+            let l4 = *arg0.add(4).cast::<*mut u8>();
+            let l5 = *arg0.add(8).cast::<usize>();
+            _rt::cabi_dealloc(l4, l5, 1);
+        }
+    }
+}
+#[doc(hidden)]
+#[allow(non_snake_case)]
+pub unsafe fn _export_print_cabi<T: Guest>(arg0: *mut u8, arg1: usize, arg2: i32) -> *mut u8 {
+    #[cfg(target_arch = "wasm32")]
+    _rt::run_ctors_once();
+    let len0 = arg1;
+    let result1 = T::print(
+        _rt::Vec::from_raw_parts(arg0.cast(), len0, len0),
+        _rt::bool_lift(arg2 as u8),
+    );
+    let ptr2 = _RET_AREA.0.as_mut_ptr().cast::<u8>();
+    match result1 {
+        Ok(e) => {
+            *ptr2.add(0).cast::<u8>() = (0i32) as u8;
+            let vec4 = e;
+            let len4 = vec4.len();
+            let layout4 = _rt::alloc::Layout::from_size_align_unchecked(vec4.len() * 12, 4);
+            let result4 = if layout4.size() != 0 {
+                let ptr = _rt::alloc::alloc(layout4).cast::<u8>();
+                if ptr.is_null() {
+                    _rt::alloc::handle_alloc_error(layout4);
+                }
+                ptr
+            } else {
+                {
+                    ::core::ptr::null_mut()
+                }
+            };
+            for (i, e) in vec4.into_iter().enumerate() {
+                let base = result4.add(i * 12);
+                {
+                    match e {
+                        PrintPart::Str(e) => {
+                            *base.add(0).cast::<u8>() = (0i32) as u8;
+                            let vec3 = (e.into_bytes()).into_boxed_slice();
+                            let ptr3 = vec3.as_ptr().cast::<u8>();
+                            let len3 = vec3.len();
+                            ::core::mem::forget(vec3);
+                            *base.add(8).cast::<usize>() = len3;
+                            *base.add(4).cast::<*mut u8>() = ptr3.cast_mut();
+                        }
+                        PrintPart::Name => {
+                            *base.add(0).cast::<u8>() = (1i32) as u8;
+                        }
+                        PrintPart::Literal => {
+                            *base.add(0).cast::<u8>() = (2i32) as u8;
+                        }
+                        PrintPart::Keyword => {
+                            *base.add(0).cast::<u8>() = (3i32) as u8;
+                        }
+                        PrintPart::Type => {
+                            *base.add(0).cast::<u8>() = (4i32) as u8;
+                        }
+                        PrintPart::Comment => {
+                            *base.add(0).cast::<u8>() = (5i32) as u8;
+                        }
+                        PrintPart::Reset => {
+                            *base.add(0).cast::<u8>() = (6i32) as u8;
+                        }
+                    }
+                }
+            }
+            *ptr2.add(8).cast::<usize>() = len4;
+            *ptr2.add(4).cast::<*mut u8>() = result4;
+        }
+        Err(e) => {
+            *ptr2.add(0).cast::<u8>() = (1i32) as u8;
+            let vec5 = (e.into_bytes()).into_boxed_slice();
+            let ptr5 = vec5.as_ptr().cast::<u8>();
+            let len5 = vec5.len();
+            ::core::mem::forget(vec5);
+            *ptr2.add(8).cast::<usize>() = len5;
+            *ptr2.add(4).cast::<*mut u8>() = ptr5.cast_mut();
+        }
+    };
+    ptr2
+}
+#[doc(hidden)]
+#[allow(non_snake_case)]
+pub unsafe fn __post_return_print<T: Guest>(arg0: *mut u8) {
+    let l0 = i32::from(*arg0.add(0).cast::<u8>());
+    match l0 {
+        0 => {
+            let l4 = *arg0.add(4).cast::<*mut u8>();
+            let l5 = *arg0.add(8).cast::<usize>();
+            let base6 = l4;
+            let len6 = l5;
+            for i in 0..len6 {
+                let base = base6.add(i * 12);
+                {
+                    let l1 = i32::from(*base.add(0).cast::<u8>());
+                    match l1 {
+                        0 => {
+                            let l2 = *base.add(4).cast::<*mut u8>();
+                            let l3 = *base.add(8).cast::<usize>();
+                            _rt::cabi_dealloc(l2, l3, 1);
+                        }
+                        1 => (),
+                        2 => (),
+                        3 => (),
+                        4 => (),
+                        5 => (),
+                        _ => (),
+                    }
+                }
+            }
+            _rt::cabi_dealloc(base6, len6 * 12, 4);
+        }
+        _ => {
+            let l7 = *arg0.add(4).cast::<*mut u8>();
+            let l8 = *arg0.add(8).cast::<usize>();
+            _rt::cabi_dealloc(l7, l8, 1);
+        }
+    }
+}
+pub trait Guest {
+    fn parse(contents: _rt::String) -> Result<_rt::Vec<u8>, _rt::String>;
+    fn print(contents: _rt::Vec<u8>, skeleton: bool) -> Result<_rt::Vec<PrintPart>, _rt::String>;
+}
+#[doc(hidden)]
+
+macro_rules! __export_world_wasm_tools_cabi{
+  ($ty:ident with_types_in $($path_to_types:tt)*) => (const _: () = {
+
+    #[export_name = "parse"]
+    unsafe extern "C" fn export_parse(arg0: *mut u8,arg1: usize,) -> *mut u8 {
+      $($path_to_types)*::_export_parse_cabi::<$ty>(arg0, arg1)
+    }
+    #[export_name = "cabi_post_parse"]
+    unsafe extern "C" fn _post_return_parse(arg0: *mut u8,) {
+      $($path_to_types)*::__post_return_parse::<$ty>(arg0)
+    }
+    #[export_name = "print"]
+    unsafe extern "C" fn export_print(arg0: *mut u8,arg1: usize,arg2: i32,) -> *mut u8 {
+      $($path_to_types)*::_export_print_cabi::<$ty>(arg0, arg1, arg2)
+    }
+    #[export_name = "cabi_post_print"]
+    unsafe extern "C" fn _post_return_print(arg0: *mut u8,) {
+      $($path_to_types)*::__post_return_print::<$ty>(arg0)
+    }
+  };);
+}
+#[doc(hidden)]
+pub(crate) use __export_world_wasm_tools_cabi;
+#[repr(align(4))]
+struct _RetArea([::core::mem::MaybeUninit<u8>; 12]);
+static mut _RET_AREA: _RetArea = _RetArea([::core::mem::MaybeUninit::uninit(); 12]);
+mod _rt {
+    pub use alloc_crate::string::String;
+
+    #[cfg(target_arch = "wasm32")]
+    pub fn run_ctors_once() {
+        wit_bindgen_rt::run_ctors_once();
+    }
+    pub use alloc_crate::vec::Vec;
+    pub unsafe fn string_lift(bytes: Vec<u8>) -> String {
+        if cfg!(debug_assertions) {
+            String::from_utf8(bytes).unwrap()
+        } else {
+            String::from_utf8_unchecked(bytes)
+        }
+    }
+    pub unsafe fn cabi_dealloc(ptr: *mut u8, size: usize, align: usize) {
+        if size == 0 {
+            return;
+        }
+        let layout = alloc::Layout::from_size_align_unchecked(size, align);
+        alloc::dealloc(ptr as *mut u8, layout);
+    }
+    pub unsafe fn bool_lift(val: u8) -> bool {
+        if cfg!(debug_assertions) {
+            match val {
+                0 => false,
+                1 => true,
+                _ => panic!("invalid bool discriminant"),
+            }
+        } else {
+            val != 0
+        }
+    }
+    pub use alloc_crate::alloc;
+    extern crate alloc as alloc_crate;
+}
+
+/// Generates `#[no_mangle]` functions to export the specified type as the
+/// root implementation of all generated traits.
+///
+/// For more information see the documentation of `wit_bindgen::generate!`.
+///
+/// ```rust
+/// # macro_rules! export{ ($($t:tt)*) => (); }
+/// # trait Guest {}
+/// struct MyType;
+///
+/// impl Guest for MyType {
+///     // ...
+/// }
+///
+/// export!(MyType);
+/// ```
+#[allow(unused_macros)]
+#[doc(hidden)]
+
+macro_rules! __export_wasm_tools_impl {
+  ($ty:ident) => (self::export!($ty with_types_in self););
+  ($ty:ident with_types_in $($path_to_types_root:tt)*) => (
+  $($path_to_types_root)*::__export_world_wasm_tools_cabi!($ty with_types_in $($path_to_types_root)*);
+  )
+}
+#[doc(inline)]
+pub(crate) use __export_wasm_tools_impl as export;
+
+#[cfg(target_arch = "wasm32")]
+#[link_section = "component-type:wit-bindgen:0.25.0:wasm-tools:encoded world"]
+#[doc(hidden)]
+pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 326] = *b"\
+\0asm\x0d\0\x01\0\0\x19\x16wit-component-encoding\x04\0\x07\xc5\x01\x01A\x02\x01\
+A\x0a\x01q\x07\x03str\x01s\0\x04name\0\0\x07literal\0\0\x07keyword\0\0\x04type\0\
+\0\x07comment\0\0\x05reset\0\0\x03\0\x0aprint-part\x03\0\0\x01p}\x01j\x01\x02\x01\
+s\x01@\x01\x08contentss\0\x03\x04\0\x05parse\x01\x04\x01p\x01\x01j\x01\x05\x01s\x01\
+@\x02\x08contents\x02\x08skeleton\x7f\0\x06\x04\0\x05print\x01\x07\x04\x01\x1eco\
+mponent:component/wasm-tools\x04\0\x0b\x10\x01\0\x0awasm-tools\x03\0\0\0G\x09pro\
+ducers\x01\x0cprocessed-by\x02\x0dwit-component\x070.208.1\x10wit-bindgen-rust\x06\
+0.25.0";
+
+#[inline(never)]
+#[doc(hidden)]
+#[cfg(target_arch = "wasm32")]
+pub fn __link_custom_section_describing_imports() {
+    wit_bindgen_rt::maybe_link_cabi_realloc();
+}
diff --git a/playground/component/src/lib.rs b/playground/component/src/lib.rs
new file mode 100644
index 00000000..4f493e1e
--- /dev/null
+++ b/playground/component/src/lib.rs
@@ -0,0 +1,63 @@
+#[allow(warnings)]
+mod bindings;
+
+use bindings::{Guest, PrintPart};
+
+struct Component;
+
+struct StringWriter(pub Vec<PrintPart>);
+
+impl wasmprinter::Print for StringWriter {
+    fn write_str(&mut self, s: &str) -> std::io::Result<()> {
+        self.0.push(PrintPart::Str(s.to_string()));
+        Ok(())
+    }
+
+    fn start_name(&mut self) -> std::io::Result<()> {
+        self.0.push(PrintPart::Name);
+        Ok(())
+    }
+
+    fn start_literal(&mut self) -> std::io::Result<()> {
+        self.0.push(PrintPart::Literal);
+        Ok(())
+    }
+
+    fn start_keyword(&mut self) -> std::io::Result<()> {
+        self.0.push(PrintPart::Keyword);
+        Ok(())
+    }
+
+    fn start_type(&mut self) -> std::io::Result<()> {
+        self.0.push(PrintPart::Type);
+        Ok(())
+    }
+
+    fn start_comment(&mut self) -> std::io::Result<()> {
+        self.0.push(PrintPart::Comment);
+        Ok(())
+    }
+
+    fn reset_color(&mut self) -> std::io::Result<()> {
+        self.0.push(PrintPart::Reset);
+        Ok(())
+    }
+}
+
+impl Guest for Component {
+    fn parse(contents: String) -> Result<Vec<u8>, String> {
+        wat::parse_str(contents).map_err(|e| e.to_string())
+    }
+
+    fn print(contents: Vec<u8>, skeleton: bool) -> Result<Vec<PrintPart>, String> {
+        let mut config = wasmprinter::Config::new();
+        config.print_skeleton(skeleton);
+
+        let mut writer = StringWriter(Vec::new());
+        let result = config.print(&contents, &mut writer);
+
+        result.map(|_| writer.0).map_err(|e| e.to_string())
+    }
+}
+
+bindings::export!(Component with_types_in bindings);
diff --git a/playground/component/wit/world.wit b/playground/component/wit/world.wit
new file mode 100644
index 00000000..9d70466a
--- /dev/null
+++ b/playground/component/wit/world.wit
@@ -0,0 +1,16 @@
+package component:component;
+
+world wasm-tools {
+    export parse: func(contents: string) -> result<list<u8>, string>;
+
+    variant print-part {
+        str(string),
+        name,
+        literal,
+        keyword,
+        %type,
+        comment,
+        reset,
+    }
+    export print: func(contents: list<u8>, skeleton: bool) -> result<list<print-part>, string>;
+}
diff --git a/playground/package-lock.json b/playground/package-lock.json
new file mode 100644
index 00000000..5072d599
--- /dev/null
+++ b/playground/package-lock.json
@@ -0,0 +1,798 @@
+{
+  "name": "playground",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "@bytecodealliance/jco": "^1.2.4",
+        "esbuild": "^0.21.4",
+        "typescript": "^5.4.5"
+      }
+    },
+    "node_modules/@bytecodealliance/jco": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@bytecodealliance/jco/-/jco-1.2.4.tgz",
+      "integrity": "sha512-uuOm9UkYqWp5uElYDNzlhjbdrAmczEvETgQdI1hFTk79h+mrmHPRV32pgRP5o1eHVwMIpuk4XQkDIhFbksurUw==",
+      "dependencies": {
+        "@bytecodealliance/preview2-shim": "^0.16.2",
+        "binaryen": "^116.0.0",
+        "chalk-template": "^1",
+        "commander": "^12",
+        "mkdirp": "^3",
+        "ora": "^8",
+        "terser": "^5"
+      },
+      "bin": {
+        "jco": "src/jco.js"
+      }
+    },
+    "node_modules/@bytecodealliance/preview2-shim": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.16.2.tgz",
+      "integrity": "sha512-36MwesmbLSf3Y5/OHcS85iBaF0N92CQ4gpjtDVKSbrjxmrBKCWlWVfoQ03F/cqDg8k5K7pzVaVBH0XBIbTCfTQ=="
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.4.tgz",
+      "integrity": "sha512-Zrm+B33R4LWPLjDEVnEqt2+SLTATlru1q/xYKVn8oVTbiRBGmK2VIMoIYGJDGyftnGaC788IuzGFAlb7IQ0Y8A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.4.tgz",
+      "integrity": "sha512-E7H/yTd8kGQfY4z9t3nRPk/hrhaCajfA3YSQSBrst8B+3uTcgsi8N+ZWYCaeIDsiVs6m65JPCaQN/DxBRclF3A==",
+      "cpu": [
+        "arm"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.4.tgz",
+      "integrity": "sha512-fYFnz+ObClJ3dNiITySBUx+oNalYUT18/AryMxfovLkYWbutXsct3Wz2ZWAcGGppp+RVVX5FiXeLYGi97umisA==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.4.tgz",
+      "integrity": "sha512-mDqmlge3hFbEPbCWxp4fM6hqq7aZfLEHZAKGP9viq9wMUBVQx202aDIfc3l+d2cKhUJM741VrCXEzRFhPDKH3Q==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.4.tgz",
+      "integrity": "sha512-72eaIrDZDSiWqpmCzVaBD58c8ea8cw/U0fq/PPOTqE3c53D0xVMRt2ooIABZ6/wj99Y+h4ksT/+I+srCDLU9TA==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.4.tgz",
+      "integrity": "sha512-uBsuwRMehGmw1JC7Vecu/upOjTsMhgahmDkWhGLWxIgUn2x/Y4tIwUZngsmVb6XyPSTXJYS4YiASKPcm9Zitag==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.4.tgz",
+      "integrity": "sha512-8JfuSC6YMSAEIZIWNL3GtdUT5NhUA/CMUCpZdDRolUXNAXEE/Vbpe6qlGLpfThtY5NwXq8Hi4nJy4YfPh+TwAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.4.tgz",
+      "integrity": "sha512-8d9y9eQhxv4ef7JmXny7591P/PYsDFc4+STaxC1GBv0tMyCdyWfXu2jBuqRsyhY8uL2HU8uPyscgE2KxCY9imQ==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.4.tgz",
+      "integrity": "sha512-2rqFFefpYmpMs+FWjkzSgXg5vViocqpq5a1PSRgT0AvSgxoXmGF17qfGAzKedg6wAwyM7UltrKVo9kxaJLMF/g==",
+      "cpu": [
+        "arm"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.4.tgz",
+      "integrity": "sha512-/GLD2orjNU50v9PcxNpYZi+y8dJ7e7/LhQukN3S4jNDXCKkyyiyAz9zDw3siZ7Eh1tRcnCHAo/WcqKMzmi4eMQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.4.tgz",
+      "integrity": "sha512-pNftBl7m/tFG3t2m/tSjuYeWIffzwAZT9m08+9DPLizxVOsUl8DdFzn9HvJrTQwe3wvJnwTdl92AonY36w/25g==",
+      "cpu": [
+        "ia32"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.4.tgz",
+      "integrity": "sha512-cSD2gzCK5LuVX+hszzXQzlWya6c7hilO71L9h4KHwqI4qeqZ57bAtkgcC2YioXjsbfAv4lPn3qe3b00Zt+jIfQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.4.tgz",
+      "integrity": "sha512-qtzAd3BJh7UdbiXCrg6npWLYU0YpufsV9XlufKhMhYMJGJCdfX/G6+PNd0+v877X1JG5VmjBLUiFB0o8EUSicA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.4.tgz",
+      "integrity": "sha512-yB8AYzOTaL0D5+2a4xEy7OVvbcypvDR05MsB/VVPVA7nL4hc5w5Dyd/ddnayStDgJE59fAgNEOdLhBxjfx5+dg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.4.tgz",
+      "integrity": "sha512-Y5AgOuVzPjQdgU59ramLoqSSiXddu7F3F+LI5hYy/d1UHN7K5oLzYBDZe23QmQJ9PIVUXwOdKJ/jZahPdxzm9w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.4.tgz",
+      "integrity": "sha512-Iqc/l/FFwtt8FoTK9riYv9zQNms7B8u+vAI/rxKuN10HgQIXaPzKZc479lZ0x6+vKVQbu55GdpYpeNWzjOhgbA==",
+      "cpu": [
+        "s390x"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.4.tgz",
+      "integrity": "sha512-Td9jv782UMAFsuLZINfUpoF5mZIbAj+jv1YVtE58rFtfvoKRiKSkRGQfHTgKamLVT/fO7203bHa3wU122V/Bdg==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.4.tgz",
+      "integrity": "sha512-Awn38oSXxsPMQxaV0Ipb7W/gxZtk5Tx3+W+rAPdZkyEhQ6968r9NvtkjhnhbEgWXYbgV+JEONJ6PcdBS+nlcpA==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.4.tgz",
+      "integrity": "sha512-IsUmQeCY0aU374R82fxIPu6vkOybWIMc3hVGZ3ChRwL9hA1TwY+tS0lgFWV5+F1+1ssuvvXt3HFqe8roCip8Hg==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.4.tgz",
+      "integrity": "sha512-hsKhgZ4teLUaDA6FG/QIu2q0rI6I36tZVfM4DBZv3BG0mkMIdEnMbhc4xwLvLJSS22uWmaVkFkqWgIS0gPIm+A==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.4.tgz",
+      "integrity": "sha512-UUfMgMoXPoA/bvGUNfUBFLCh0gt9dxZYIx9W4rfJr7+hKe5jxxHmfOK8YSH4qsHLLN4Ck8JZ+v7Q5fIm1huErg==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.4.tgz",
+      "integrity": "sha512-yIxbspZb5kGCAHWm8dexALQ9en1IYDfErzjSEq1KzXFniHv019VT3mNtTK7t8qdy4TwT6QYHI9sEZabONHg+aw==",
+      "cpu": [
+        "ia32"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.4.tgz",
+      "integrity": "sha512-sywLRD3UK/qRJt0oBwdpYLBibk7KiRfbswmWRDabuncQYSlf8aLEEUor/oP6KRz8KEG+HoiVLBhPRD5JWjS8Sg==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+      "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+      "dependencies": {
+        "@jridgewell/set-array": "^1.2.1",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/set-array": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+      "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/source-map": {
+      "version": "0.3.6",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
+      "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.25"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.4.15",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+      "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.25",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+      "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.11.3",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
+      "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+      "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/binaryen": {
+      "version": "116.0.0",
+      "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-116.0.0.tgz",
+      "integrity": "sha512-Hp0dXC6Cb/rTwWEoUS2BRghObE7g/S9umKtxuTDt3f61G6fNTE/YVew/ezyy3IdHcLx3f17qfh6LwETgCfvWkQ==",
+      "bin": {
+        "wasm-opt": "bin/wasm-opt",
+        "wasm2js": "bin/wasm2js"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
+    },
+    "node_modules/chalk": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+      "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+      "engines": {
+        "node": "^12.17.0 || ^14.13 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chalk-template": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.0.tgz",
+      "integrity": "sha512-T2VJbcDuZQ0Tb2EWwSotMPJjgpy1/tGee1BTpUNsGZ/qgNjV2t7Mvu+d4600U564nbLesN1x2dPL+xii174Ekg==",
+      "dependencies": {
+        "chalk": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk-template?sponsor=1"
+      }
+    },
+    "node_modules/cli-cursor": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
+      "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
+      "dependencies": {
+        "restore-cursor": "^4.0.0"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/cli-spinners": {
+      "version": "2.9.2",
+      "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+      "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/commander": {
+      "version": "12.1.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+      "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/emoji-regex": {
+      "version": "10.3.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz",
+      "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw=="
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.4",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.4.tgz",
+      "integrity": "sha512-sFMcNNrj+Q0ZDolrp5pDhH0nRPN9hLIM3fRPwgbLYJeSHHgnXSnbV3xYgSVuOeLWH9c73VwmEverVzupIv5xuA==",
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.4",
+        "@esbuild/android-arm": "0.21.4",
+        "@esbuild/android-arm64": "0.21.4",
+        "@esbuild/android-x64": "0.21.4",
+        "@esbuild/darwin-arm64": "0.21.4",
+        "@esbuild/darwin-x64": "0.21.4",
+        "@esbuild/freebsd-arm64": "0.21.4",
+        "@esbuild/freebsd-x64": "0.21.4",
+        "@esbuild/linux-arm": "0.21.4",
+        "@esbuild/linux-arm64": "0.21.4",
+        "@esbuild/linux-ia32": "0.21.4",
+        "@esbuild/linux-loong64": "0.21.4",
+        "@esbuild/linux-mips64el": "0.21.4",
+        "@esbuild/linux-ppc64": "0.21.4",
+        "@esbuild/linux-riscv64": "0.21.4",
+        "@esbuild/linux-s390x": "0.21.4",
+        "@esbuild/linux-x64": "0.21.4",
+        "@esbuild/netbsd-x64": "0.21.4",
+        "@esbuild/openbsd-x64": "0.21.4",
+        "@esbuild/sunos-x64": "0.21.4",
+        "@esbuild/win32-arm64": "0.21.4",
+        "@esbuild/win32-ia32": "0.21.4",
+        "@esbuild/win32-x64": "0.21.4"
+      }
+    },
+    "node_modules/get-east-asian-width": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz",
+      "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-interactive": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
+      "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-unicode-supported": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz",
+      "integrity": "sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/log-symbols": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
+      "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
+      "dependencies": {
+        "chalk": "^5.3.0",
+        "is-unicode-supported": "^1.3.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/log-symbols/node_modules/is-unicode-supported": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
+      "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+      "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
+      "bin": {
+        "mkdirp": "dist/cjs/src/bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "dependencies": {
+        "mimic-fn": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/ora": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/ora/-/ora-8.0.1.tgz",
+      "integrity": "sha512-ANIvzobt1rls2BDny5fWZ3ZVKyD6nscLvfFRpQgfWsythlcsVUC9kL0zq6j2Z5z9wwp1kd7wpsD/T9qNPVLCaQ==",
+      "dependencies": {
+        "chalk": "^5.3.0",
+        "cli-cursor": "^4.0.0",
+        "cli-spinners": "^2.9.2",
+        "is-interactive": "^2.0.0",
+        "is-unicode-supported": "^2.0.0",
+        "log-symbols": "^6.0.0",
+        "stdin-discarder": "^0.2.1",
+        "string-width": "^7.0.0",
+        "strip-ansi": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/restore-cursor": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
+      "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
+      "dependencies": {
+        "onetime": "^5.1.0",
+        "signal-exit": "^3.0.2"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-support": {
+      "version": "0.5.21",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "node_modules/stdin-discarder": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
+      "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.1.0.tgz",
+      "integrity": "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==",
+      "dependencies": {
+        "emoji-regex": "^10.3.0",
+        "get-east-asian-width": "^1.0.0",
+        "strip-ansi": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+      "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+      "dependencies": {
+        "ansi-regex": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/terser": {
+      "version": "5.31.1",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.1.tgz",
+      "integrity": "sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==",
+      "dependencies": {
+        "@jridgewell/source-map": "^0.3.3",
+        "acorn": "^8.8.2",
+        "commander": "^2.20.0",
+        "source-map-support": "~0.5.20"
+      },
+      "bin": {
+        "terser": "bin/terser"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/terser/node_modules/commander": {
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
+    },
+    "node_modules/typescript": {
+      "version": "5.4.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+      "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    }
+  }
+}
diff --git a/playground/package.json b/playground/package.json
new file mode 100644
index 00000000..165eede1
--- /dev/null
+++ b/playground/package.json
@@ -0,0 +1,17 @@
+{
+  "type": "module",
+  "scripts": {
+    "build": "npm run build-component && npm run transpile && npm run typecheck && npm run bundle && npm run copy-files",
+    "build-component": "cd component && cargo component build --release --target wasm32-wasi",
+    "bundle": "for source in worker.ts parse.ts print.ts; do esbuild --bundle --format=esm src/$source --outdir=dist; done",
+    "copy-files": "cp pages/* component-built/*.wasm dist",
+    "clean": "rm -rf component/src/bindings.rs component-built dist && echo '## you will need to run `cargo clean` seperately to delete cargo artifacts'",
+    "transpile": "jco transpile --no-nodejs-compat ../target/wasm32-wasi/release/component.wasm --out-dir component-built",
+    "typecheck": "tsc"
+  },
+  "dependencies": {
+    "@bytecodealliance/jco": "^1.2.4",
+    "esbuild": "^0.21.4",
+    "typescript": "^5.4.5"
+  }
+}
diff --git a/playground/pages/index.html b/playground/pages/index.html
new file mode 100644
index 00000000..38c39b75
--- /dev/null
+++ b/playground/pages/index.html
@@ -0,0 +1,2 @@
+<!doctype html>
+<meta http-equiv="refresh" content="0; url=https://github.com/bytecodealliance/wasm-tools">
diff --git a/playground/pages/parse.html b/playground/pages/parse.html
new file mode 100644
index 00000000..ae17c564
--- /dev/null
+++ b/playground/pages/parse.html
@@ -0,0 +1,86 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <title>wasm-tools parse</title>
+  <script type="module" src="parse.js"></script>
+  <link rel="stylesheet" href="style.css">
+  <style>
+    .textarea-container {
+      flex-grow: 1;
+      padding: .4rem;
+      display: grid;
+      grid-column-gap: calc(.8rem + 8px);;
+      grid-template-columns: 1fr 1fr;
+      grid-template-rows: min-content auto 1fr;
+      grid-template-areas:
+        "h h"
+        "a b"
+        "c d"
+    }
+
+    textarea {
+      resize: none;
+      box-sizing: border-box;
+      padding: .4rem;
+      height: calc(100% - .4rem);
+      width: 100%;
+    }
+
+    button {
+      font-size: inherit;
+      /* color: #505050; */
+    }
+
+    .description {
+      grid-area: h;
+    }
+
+    .input-header {
+      grid-area: a;
+      padding-bottom: .4rem;
+    }
+    .output-header {
+      grid-area: b;
+    }
+
+    .input {
+      grid-area: c;
+    }
+    .output {
+      grid-area: d
+    }
+  </style>
+</head>
+
+<header>
+  <h1>wasm-tools parse</h1>
+</header>
+
+<div class="textarea-container">
+  <div class="description">
+    <p>Translate from the WebAssembly text format to binary using <a href="https://github.com/bytecodealliance/wasm-tools"><code>wasm-tools parse</code></a>.
+    You can go the other direction using <a href="/print"><code>print</code></a>.</p>
+  </div>
+  <div class="input-header">wat</div>
+  <div class="input">
+    <textarea id="input">(module
+  (import "foo" "bar" (func (param f32)))
+  (memory (data "hi"))
+  (type (func (param i32) (result i32)))
+  (start 1)
+  (table 0 1 funcref)
+  (func)
+  (func (type 1)
+    i32.const 42
+    drop)
+  (export "e" (func 1)))</textarea>
+  </div>
+
+  <div class="output-header">
+    wasm <button id="download" disabled>Download</button>
+  </div>
+  <div class="output">
+    <textarea id="output"></textarea>
+  </div>
+</div>
diff --git a/playground/pages/print.html b/playground/pages/print.html
new file mode 100644
index 00000000..c16657bd
--- /dev/null
+++ b/playground/pages/print.html
@@ -0,0 +1,115 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <title>wasm-tools print</title>
+  <script type="module" src="print.js"></script>
+  <link rel="stylesheet" href="style.css">
+  <style>
+    .textarea-container {
+      flex-grow: 1;
+      padding: .4rem;
+      display: grid;
+      grid-column-gap: calc(.8rem + 8px);;
+      grid-template-rows: min-content auto 1fr;
+      grid-template-areas:
+        "h"
+        "a"
+        "o"
+    }
+
+    button {
+      font-size: inherit;
+    }
+
+    .config {
+      float: right;
+    }
+
+    .description {
+      grid-area: h;
+    }
+
+    .output-header {
+      grid-area: a;
+      padding-bottom: .4rem;
+    }
+
+    .output-container {
+      grid-area: o;
+      overflow-x: scroll;
+    }
+
+    #output {
+      box-sizing: border-box;
+      padding: .4rem;
+      min-height: calc(100% - .4rem);
+      width: 100%;
+      margin: 0;
+      border: 1px solid black;
+      border-radius: 5px;
+      overflow-x: scroll;
+    }
+
+    #overlay {
+      position: fixed;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      background-color: rgba(0, 0, 0, .2);
+      display: none;
+      justify-content: center;
+      align-items: center;
+      text-shadow: 0 0 2px white;
+      font-size: 24px;
+      pointer-events: none;
+    }
+
+    .print-name {
+      color: #9F00BD;
+    }
+
+    .print-literal {
+      color: #FB0416;
+    }
+
+    .print-keyword {
+      font-weight: bold;
+    }
+
+    .print-type {
+      color: #328A5D;
+    }
+
+    .print-comment {
+      color: #B3B3B3;
+    }
+  </style>
+</head>
+
+<header>
+  <h1>wasm-tools print</h1>
+</header>
+
+<div id="overlay">Drop .wasm file here</div>
+
+<div class="textarea-container">
+  <div class="description">
+    <p>Translate WebAssembly binaries to text format using <a href="https://github.com/bytecodealliance/wasm-tools"><code>wasm-tools print</code></a>.
+    You can go the other direction using <a href="/parse"><code>parse</code></a>.</p>
+  </div>
+
+  <div class="output-header">
+    Drop a file on this page, or <button id="upload">manually select a file</button>.
+    <input type="file" id="fileInput" accept=".wasm" style="display:none;">
+    <span class="config">
+      <input type="checkbox" id="skeleton">
+      <label for="skeleton">Skip function bodies (recommended for large files)</label>
+    </span>
+  </div>
+  <div class="output-container">
+    <!-- adding contenteditable is the easiest way to let the user copy-paste the whole contents -->
+    <pre id="output" class="iobox" contenteditable="plaintext-only" spellcheck="false"></pre>
+  </div>
+</div>
diff --git a/playground/pages/style.css b/playground/pages/style.css
new file mode 100644
index 00000000..223c1219
--- /dev/null
+++ b/playground/pages/style.css
@@ -0,0 +1,31 @@
+:root {
+  --text-color: #404040;
+}
+
+html {
+  height: 100%;
+}
+
+body {
+  font-family: system,-apple-system,system-ui,BlinkMacSystemFont,sans-serif;
+  color: var(--text-color);
+  font-size: 1.2rem;
+  line-height: 1.3;
+  height: calc(100% - 16px - .4rem);
+  display: flex;
+  flex-direction: column;
+  padding: .4rem
+}
+
+header {
+  text-align: center;
+}
+
+h1 {
+  margin: 0px;
+}
+
+a {
+  color: var(--text-color);
+}
+
diff --git a/playground/src/parse.ts b/playground/src/parse.ts
new file mode 100644
index 00000000..2d0618e4
--- /dev/null
+++ b/playground/src/parse.ts
@@ -0,0 +1,66 @@
+import { type MessageToWorker, debounce } from './utilities.js';
+
+// TODO replace this function once https://github.com/tc39/proposal-arraybuffer-base64 ships
+function base64(bytes: Uint8Array) {
+  // @ts-expect-error it's fine to pass a Uint8Array here
+  return btoa(String.fromCharCode.apply(null, bytes));
+}
+
+let input: HTMLTextAreaElement = document.querySelector('#input')!;
+let output: HTMLTextAreaElement = document.querySelector('#output')!;
+let downloadButton: HTMLButtonElement = document.querySelector('#download')!;
+
+let worker = new Worker('worker.js', { type: 'module' });
+
+let lastBytes: Uint8Array;
+let sent = '';
+let statusMessageTimeout = 0;
+
+// we can't just postmessage to the worker right away because it has a top-level await
+// so we need to wait for it to tell us it's ready
+worker.addEventListener('message', () => {
+  worker.addEventListener('message', gotMessage);
+  update();
+}, { once: true });
+
+function gotMessage({ data }: { data: { success: true, bytes: Uint8Array } | { success: false, error: string }}) {
+  clearTimeout(statusMessageTimeout);
+  if (input.value === sent) {
+    if (data.success) {
+      lastBytes = data.bytes;
+      output.value = base64(lastBytes);
+      downloadButton.disabled = false;
+      output.style.color = '';
+    } else {
+      output.value = data.error;
+      downloadButton.disabled = true;
+      output.style.color = '#a00';
+    }
+  } else {
+    // the user has already moved on, ignore this result and re-run
+    update();
+  }
+}
+
+function update() {
+  sent = input.value;
+  worker.postMessage({ kind: 'parse', source: input.value } satisfies MessageToWorker);
+
+  // wait a bit before updating the output to avoid rapid changes when the output can be parsed quickly
+  statusMessageTimeout = setTimeout(() => {
+    output.value = 'working...';
+    downloadButton.disabled = true;
+  }, 200);
+}
+
+input.addEventListener('input', debounce(update, 100));
+
+downloadButton.addEventListener('click', () => {
+  let blob = new Blob([lastBytes], { type: 'application/wasm' });
+  let link = document.createElement('a');
+  let url = URL.createObjectURL(blob);
+  link.href = url;
+  link.download = 'wasm-tools-parse-output.wasm';
+  link.click();
+  URL.revokeObjectURL(url);
+});
diff --git a/playground/src/print.ts b/playground/src/print.ts
new file mode 100644
index 00000000..7b22e07a
--- /dev/null
+++ b/playground/src/print.ts
@@ -0,0 +1,118 @@
+import type { MessageToWorker } from './utilities.js';
+import type { PrintPart } from '../component-built/component.js';
+
+function unbase64(chars: string) {
+  return new Uint8Array(Array.from(atob(chars), c => c.charCodeAt(0)))
+}
+
+let overlay: HTMLDivElement = document.querySelector('#overlay')!;
+let upload: HTMLButtonElement = document.querySelector('#upload')!;
+let fileInput: HTMLInputElement = document.querySelector('#fileInput')!;
+let output: HTMLPreElement = document.querySelector('#output')!;
+let skeletonCheckbox: HTMLInputElement = document.querySelector('#skeleton')!;
+
+let lastTarget: EventTarget | null;
+let hideOverlayTimer: number;
+addEventListener('dragover', e => {
+  e.preventDefault(); // necessary to make the drop event work, for some reason
+  lastTarget = e.target;
+  overlay.style.display = 'flex';
+  clearTimeout(hideOverlayTimer);
+});
+
+addEventListener('dragleave', e => {
+  e.preventDefault();
+  if (e.target === document || e.target === lastTarget) {
+    // we need a timer here because otherwise the overlay flashes while moving the mouse around the page
+    hideOverlayTimer = setTimeout(() => {
+      overlay.style.display = 'none';
+    }, 10);
+  }
+});
+
+addEventListener('drop', (e: DragEvent) => {
+  e.preventDefault();
+  overlay.style.display = 'none';
+  let file = e.dataTransfer?.files[0];
+  if (!file) return;
+  print(file.arrayBuffer());
+}, { capture: true });
+
+let messageId = 0;
+async function print(f: Promise<ArrayBuffer>) {
+  output.innerHTML = 'working...';
+  let skeleton = skeletonCheckbox.checked;
+  let bytes = new Uint8Array(await f);
+  worker.postMessage({ kind: 'print', messageId, bytes, skeleton } satisfies MessageToWorker, [bytes.buffer]);
+  ++messageId;
+}
+
+let worker = new Worker('worker.js', { type: 'module' });
+
+// we can't just postmessage to the worker right away because it has a top-level await
+// so we need to wait for it to tell us it's ready
+worker.addEventListener('message', () => {
+  worker.addEventListener('message', gotMessage);
+  print(Promise.resolve(unbase64('AGFzbQEAAAABDQNgAX8Bf2ABfQBgAAACCwEDZm9vA2JhcgABAwMCAgEEBQFwAQABBQQBAQEBBwUBAWUAAQgBAQoKAgIACwUAQSoaCwsIAQBBAAsCaGk=').buffer));
+}, { once: true },);
+
+upload.addEventListener('click', () => {
+  fileInput.click();
+  fileInput.onchange = () => {
+    let file = fileInput.files?.[0];
+    if (!file) return;
+    print(file.arrayBuffer());
+  }
+});
+
+function gotMessage({ data }: {
+  data: { messageId: number; success: true; source: PrintPart[] } | { messageId: number; success: false; error: string };
+}) {
+  if (data.messageId !== messageId - 1) {
+    // user has already asked for a new file
+    return;
+  }
+  output.innerHTML = '';
+  if (data.success) {
+    output.append(toDOM(data.source));
+  } else {
+    let span = document.createElement('span');
+    span.style.color = '#a00';
+    span.append(data.error);
+    output.append(span);
+  }
+}
+
+function toDOM(parts: PrintPart[]) {
+  let root = document.createElement('span');
+  let stack = [root];
+  for (let part of parts) {
+    switch (part.tag) {
+      case 'str': {
+        stack[0].append(part.val);
+        break;
+      }
+      case 'name':
+      case 'literal':
+      case 'keyword':
+      case 'type':
+      case 'comment': {
+        let ele = document.createElement('span');
+        ele.className = 'print-' + part.tag;
+        stack[0].append(ele);
+        stack.unshift(ele);
+        break;
+      }
+      case 'reset': {
+        stack.shift();
+        break;
+      }
+      default: {
+        // @ts-expect-error the above should be exhaustive
+        type remaining = typeof part.tag;
+        console.error('got unknown print part', part);
+      }
+    }
+  }
+  return root;
+}
diff --git a/playground/src/utilities.ts b/playground/src/utilities.ts
new file mode 100644
index 00000000..074c4a18
--- /dev/null
+++ b/playground/src/utilities.ts
@@ -0,0 +1,16 @@
+export type MessageToWorker =
+  | { kind: 'parse'; source: string }
+  | { kind: 'print'; messageId: number; bytes: Uint8Array, skeleton: boolean };
+
+export function debounce(f: (...args: unknown[]) => void, ms: number) {
+  let timeout: number | null;
+  return function (this: unknown, ...args: unknown[]) {
+    let fresh = timeout == null;
+    if (!fresh) clearTimeout(timeout!);
+    timeout = setTimeout(() => {
+      timeout = null;
+      if (!fresh) f.apply(this, args);
+    }, ms);
+    if (fresh) f.apply(this, args);
+  };
+}
diff --git a/playground/src/worker.ts b/playground/src/worker.ts
new file mode 100644
index 00000000..32336bce
--- /dev/null
+++ b/playground/src/worker.ts
@@ -0,0 +1,35 @@
+import { parse, print } from '../component-built/component.js';
+import type { MessageToWorker } from './utilities.js'
+
+// workaround for https://github.com/Microsoft/TypeScript/issues/20595
+declare function postMessage(message: any, transfer?: Transferable[]): void;
+
+postMessage('loaded');
+
+addEventListener('message', ({ data }: { data: MessageToWorker }) => {
+  switch (data.kind) {
+    case 'parse': {
+      try {
+        let bytes = parse(data.source);
+        postMessage({ success: true, bytes }, [bytes.buffer]);
+      } catch (e) {
+        postMessage({ success: false, error: (e as Error).message });
+      }
+      return;
+    }
+    case 'print': {
+      try {
+        let source = print(data.bytes, data.skeleton);
+        postMessage({ success: true, messageId: data.messageId, source });
+      } catch (e) {
+        postMessage({ success: false, messageId: data.messageId, error: (e as Error).message });
+      }
+      return;
+    }
+    default: {
+      // @ts-expect-error the above should be exhaustive
+      type remaining = typeof data.kind;
+      console.error('got unknown message', data);
+    }
+  }
+});
diff --git a/playground/tsconfig.json b/playground/tsconfig.json
new file mode 100644
index 00000000..c93177b1
--- /dev/null
+++ b/playground/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "include": ["src/**/*"],
+  "compilerOptions": {
+      "target": "es2022",
+      "module": "Node16",
+      "moduleResolution": "Node16",
+      "strict": true,
+      "verbatimModuleSyntax": true,
+      "noEmit": true,
+      "lib": ["es2022", "dom", "dom.iterable"],
+  }
+}
\ No newline at end of file
diff --git a/src/bin/wasm-tools/component.rs b/src/bin/wasm-tools/component.rs
index b238adf4..5d759df4 100644
--- a/src/bin/wasm-tools/component.rs
+++ b/src/bin/wasm-tools/component.rs
@@ -182,11 +182,19 @@ struct WitResolve {
     /// items are otherwise hidden by default.
     #[clap(long)]
     features: Vec<String>,
+
+    /// Enable all features when parsing the `wit` option.
+    ///
+    /// This flag enables all `@unstable` features in WIT documents where the
+    /// items are otherwise hidden by default.
+    #[clap(long)]
+    all_features: bool,
 }
 
 impl WitResolve {
-    fn resolve_with_features(features: &[String]) -> Resolve {
+    fn resolve_with_features(features: &[String], all_features: bool) -> Resolve {
         let mut resolve = Resolve::default();
+        resolve.all_features = all_features;
         for feature in features {
             for f in feature.split_whitespace() {
                 for f in f.split(',').filter(|s| !s.is_empty()) {
@@ -198,7 +206,7 @@ impl WitResolve {
     }
 
     fn load(&self) -> Result<(Resolve, Vec<PackageId>)> {
-        let mut resolve = Self::resolve_with_features(&self.features);
+        let mut resolve = Self::resolve_with_features(&self.features, self.all_features);
         let (pkg_ids, _) = resolve.push_path(&self.wit)?;
         Ok((resolve, pkg_ids))
     }
@@ -494,6 +502,13 @@ pub struct WitOpts {
     /// items are otherwise hidden by default.
     #[clap(long)]
     features: Vec<String>,
+
+    /// Enable all features when parsing the `wit` option.
+    ///
+    /// This flag enables all `@unstable` features in WIT documents where the
+    /// items are otherwise hidden by default.
+    #[clap(long)]
+    all_features: bool,
 }
 
 impl WitOpts {
@@ -524,7 +539,8 @@ impl WitOpts {
         // `parse_wit_from_path`.
         if let Some(input) = &self.input {
             if input.is_dir() {
-                let mut resolve = WitResolve::resolve_with_features(&self.features);
+                let mut resolve =
+                    WitResolve::resolve_with_features(&self.features, self.all_features);
                 let (pkg_ids, _) = resolve.push_dir(&input)?;
                 return Ok(DecodedWasm::WitPackages(resolve, pkg_ids));
             }
@@ -572,7 +588,8 @@ impl WitOpts {
                     Ok(s) => s,
                     Err(_) => bail!("input was not valid utf-8"),
                 };
-                let mut resolve = WitResolve::resolve_with_features(&self.features);
+                let mut resolve =
+                    WitResolve::resolve_with_features(&self.features, self.all_features);
                 let pkgs = UnresolvedPackageGroup::parse(path, input)?;
                 let ids = resolve.append(pkgs)?;
                 Ok(DecodedWasm::WitPackages(resolve, ids))


Test patch:
diff --git a/crates/wit-parser/tests/ui/feature-gates.wit.json b/crates/wit-parser/tests/ui/feature-gates.wit.json
index e211f045..9cdb44c5 100644
--- a/crates/wit-parser/tests/ui/feature-gates.wit.json
+++ b/crates/wit-parser/tests/ui/feature-gates.wit.json
@@ -7,8 +7,9 @@
           "interface": {
             "id": 4,
             "stability": {
-              "type": "unstable",
-              "feature": "active"
+              "unstable": {
+                "feature": "active"
+              }
             }
           }
         }
@@ -18,16 +19,18 @@
           "interface": {
             "id": 4,
             "stability": {
-              "type": "unstable",
-              "feature": "active"
+              "unstable": {
+                "feature": "active"
+              }
             }
           }
         }
       },
       "package": 0,
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       }
     },
     {
@@ -37,8 +40,9 @@
           "interface": {
             "id": 4,
             "stability": {
-              "type": "unstable",
-              "feature": "active"
+              "unstable": {
+                "feature": "active"
+              }
             }
           }
         }
@@ -48,8 +52,9 @@
           "interface": {
             "id": 4,
             "stability": {
-              "type": "unstable",
-              "feature": "active"
+              "unstable": {
+                "feature": "active"
+              }
             }
           }
         }
@@ -68,14 +73,16 @@
           "params": [],
           "results": [],
           "stability": {
-            "type": "unstable",
-            "feature": "active"
+            "unstable": {
+              "feature": "active"
+            }
           }
         }
       },
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       },
       "package": 0
     },
@@ -87,8 +94,9 @@
       },
       "functions": {},
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       },
       "package": 0
     },
@@ -99,8 +107,9 @@
       },
       "functions": {},
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       },
       "package": 0
     },
@@ -111,8 +120,9 @@
       },
       "functions": {},
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       },
       "package": 0
     },
@@ -121,8 +131,9 @@
       "types": {},
       "functions": {},
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       },
       "package": 0
     },
@@ -144,8 +155,9 @@
             }
           ],
           "stability": {
-            "type": "unstable",
-            "feature": "active"
+            "unstable": {
+              "feature": "active"
+            }
           }
         },
         "[static]ungated.x": {
@@ -156,8 +168,9 @@
           "params": [],
           "results": [],
           "stability": {
-            "type": "unstable",
-            "feature": "active"
+            "unstable": {
+              "feature": "active"
+            }
           }
         },
         "[method]ungated.y": {
@@ -173,8 +186,9 @@
           ],
           "results": [],
           "stability": {
-            "type": "unstable",
-            "feature": "active"
+            "unstable": {
+              "feature": "active"
+            }
           }
         }
       },
@@ -191,8 +205,9 @@
         "interface": 1
       },
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       }
     },
     {
@@ -204,8 +219,9 @@
         "interface": 1
       },
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       }
     },
     {
@@ -217,8 +233,9 @@
         "interface": 2
       },
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       }
     },
     {
@@ -230,8 +247,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       }
     },
     {
@@ -241,8 +259,9 @@
         "interface": 5
       },
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       }
     },
     {
@@ -254,8 +273,9 @@
       },
       "owner": null,
       "stability": {
-        "type": "unstable",
-        "feature": "active"
+        "unstable": {
+          "feature": "active"
+        }
       }
     },
     {
diff --git a/crates/wit-parser/tests/ui/since-and-unstable.wit.json b/crates/wit-parser/tests/ui/since-and-unstable.wit.json
index c5efc938..31c4fa0c 100644
--- a/crates/wit-parser/tests/ui/since-and-unstable.wit.json
+++ b/crates/wit-parser/tests/ui/since-and-unstable.wit.json
@@ -6,8 +6,9 @@
       "exports": {},
       "package": 0,
       "stability": {
-        "type": "stable",
-        "since": "1.0.1"
+        "stable": {
+          "since": "1.0.1"
+        }
       }
     },
     {
@@ -16,8 +17,9 @@
       "exports": {},
       "package": 0,
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -27,8 +29,9 @@
           "interface": {
             "id": 5,
             "stability": {
-              "type": "stable",
-              "since": "1.0.0"
+              "stable": {
+                "since": "1.0.0"
+              }
             }
           }
         },
@@ -36,8 +39,9 @@
           "interface": {
             "id": 4,
             "stability": {
-              "type": "stable",
-              "since": "1.0.0"
+              "stable": {
+                "since": "1.0.0"
+              }
             }
           }
         },
@@ -69,8 +73,9 @@
             "params": [],
             "results": [],
             "stability": {
-              "type": "stable",
-              "since": "1.0.0"
+              "stable": {
+                "since": "1.0.0"
+              }
             }
           }
         },
@@ -87,8 +92,9 @@
               }
             ],
             "stability": {
-              "type": "stable",
-              "since": "1.0.0"
+              "stable": {
+                "since": "1.0.0"
+              }
             }
           }
         }
@@ -101,8 +107,9 @@
             "params": [],
             "results": [],
             "stability": {
-              "type": "stable",
-              "since": "1.0.0"
+              "stable": {
+                "since": "1.0.0"
+              }
             }
           }
         },
@@ -110,8 +117,9 @@
           "interface": {
             "id": 6,
             "stability": {
-              "type": "stable",
-              "since": "1.0.0"
+              "stable": {
+                "since": "1.0.0"
+              }
             }
           }
         },
@@ -119,8 +127,9 @@
           "interface": {
             "id": 4,
             "stability": {
-              "type": "stable",
-              "since": "1.0.0"
+              "stable": {
+                "since": "1.0.0"
+              }
             }
           }
         }
@@ -134,8 +143,9 @@
       "types": {},
       "functions": {},
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       },
       "package": 0
     },
@@ -144,9 +154,10 @@
       "types": {},
       "functions": {},
       "stability": {
-        "type": "stable",
-        "since": "1.0.0",
-        "feature": "foo"
+        "stable": {
+          "since": "1.0.0",
+          "feature": "foo"
+        }
       },
       "package": 0
     },
@@ -155,9 +166,10 @@
       "types": {},
       "functions": {},
       "stability": {
-        "type": "stable",
-        "since": "1.0.0",
-        "feature": "foo-bar"
+        "stable": {
+          "since": "1.0.0",
+          "feature": "foo-bar"
+        }
       },
       "package": 0
     },
@@ -180,8 +192,9 @@
           "params": [],
           "results": [],
           "stability": {
-            "type": "stable",
-            "since": "1.0.0"
+            "stable": {
+              "since": "1.0.0"
+            }
           }
         },
         "[constructor]r3": {
@@ -196,8 +209,9 @@
             }
           ],
           "stability": {
-            "type": "stable",
-            "since": "1.0.0"
+            "stable": {
+              "since": "1.0.0"
+            }
           }
         },
         "[static]r3.x1": {
@@ -208,8 +222,9 @@
           "params": [],
           "results": [],
           "stability": {
-            "type": "stable",
-            "since": "1.0.0"
+            "stable": {
+              "since": "1.0.0"
+            }
           }
         },
         "[method]r3.x2": {
@@ -225,8 +240,9 @@
           ],
           "results": [],
           "stability": {
-            "type": "stable",
-            "since": "1.0.0"
+            "stable": {
+              "since": "1.0.0"
+            }
           }
         }
       },
@@ -243,8 +259,9 @@
       "types": {},
       "functions": {},
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       },
       "package": 0
     },
@@ -253,8 +270,9 @@
       "types": {},
       "functions": {},
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       },
       "package": 0
     }
@@ -267,8 +285,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -278,8 +297,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -291,8 +311,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -311,8 +332,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -330,8 +352,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -349,8 +372,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -369,8 +393,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -380,8 +405,9 @@
         "interface": 3
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -393,8 +419,9 @@
       },
       "owner": null,
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -413,8 +440,9 @@
         "world": 2
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -432,8 +460,9 @@
         "world": 2
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -452,8 +481,9 @@
         "world": 2
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -471,8 +501,9 @@
         "world": 2
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -484,8 +515,9 @@
         "world": 2
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -495,8 +527,9 @@
         "world": 2
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
@@ -506,8 +539,9 @@
         "world": 2
       },
       "stability": {
-        "type": "stable",
-        "since": "1.0.0"
+        "stable": {
+          "since": "1.0.0"
+        }
       }
     },
     {
diff --git a/tests/cli/wit-with-all-features.wit b/tests/cli/wit-with-all-features.wit
new file mode 100644
index 00000000..cdae70c7
--- /dev/null
+++ b/tests/cli/wit-with-all-features.wit
@@ -0,0 +1,18 @@
+// RUN: component wit --all-features %
+
+package a:b;
+
+@unstable(feature = foo)
+interface foo {
+  @unstable(feature = foo)
+  type t = u32;
+}
+
+@unstable(feature = bar)
+interface bar {
+  @unstable(feature = baz)
+  use foo.{t};
+
+  @unstable(feature = qux)
+  record x { y: t }
+}
diff --git a/tests/cli/wit-with-all-features.wit.stdout b/tests/cli/wit-with-all-features.wit.stdout
new file mode 100644
index 00000000..a193ef77
--- /dev/null
+++ b/tests/cli/wit-with-all-features.wit.stdout
@@ -0,0 +1,20 @@
+/// RUN: component wit --all-features %
+package a:b;
+
+@unstable(feature = foo)
+interface foo {
+  @unstable(feature = foo)
+  type t = u32;
+}
+
+@unstable(feature = bar)
+interface bar {
+  @unstable(feature = baz)
+  use foo.{t};
+
+  @unstable(feature = qux)
+  record x {
+    y: t,
+  }
+}
+