@miaskiewicz/turbo-dom 0.5.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml CHANGED
@@ -1,13 +1,53 @@
1
1
  [workspace]
2
2
  # The root package (turbo-dom-parser) plus the standalone Rust-native DOM runtime
3
- # crate published to crates.io as `turbo-dom`. The parser ships to npm.
4
- members = ["crates/turbo-dom"]
3
+ # crate published to crates.io as `turbo-dom`, plus the add-on crates:
4
+ # `turbo-dom-browser-env` (rtdom <-> V8 browser binding), `turbo-dom-layout` (HTML/CSS
5
+ # layout engine) and `turbo-dom-raster` (screenshot painter), the last two relocated
6
+ # from turbo-html2pdf-core / turbo-surf-raster. The parser ships to npm.
7
+ members = ["crates/turbo-dom", "crates/turbo-dom-browser-env", "crates/turbo-dom-layout", "crates/turbo-dom-raster"]
8
+ # turbo-dom-v8 is generated (crates/turbo-dom-v8/scripts/generate.py) into the
9
+ # gitignored crates/turbo-dom-v8/crate/ and built as its own standalone package, so a
10
+ # fresh checkout resolves this workspace without generating V8 first.
11
+ exclude = ["crates/turbo-dom-v8"]
12
+
13
+ # The pedantic clippy gate shared by the turbo-dom, turbo-dom-layout and
14
+ # turbo-dom-raster crates (`[lints] workspace = true`; the root parser package keeps its
15
+ # own copy below, which adds one FFI-specific allowance; turbo-dom-browser-env is
16
+ # lint-exempt by design). The allowed lints don't fit a DOM / layout / raster engine —
17
+ # "fixing" them would mangle correct, intentional code, so each is a documented opt-out
18
+ # rather than a silent suppression. Item-level `#[allow]`s in layout/raster carry their
19
+ # reason inline.
20
+ [workspace.lints.clippy]
21
+ pedantic = { level = "warn", priority = -1 }
22
+ # We bridge f64 layout math and integer indices/lengths with bounded, intentional casts.
23
+ cast_possible_truncation = "allow"
24
+ cast_precision_loss = "allow"
25
+ cast_sign_loss = "allow"
26
+ cast_possible_wrap = "allow"
27
+ # Exact float comparisons here test canonical/sentinel values (e.g. `0.0`), not approximations.
28
+ float_cmp = "allow"
29
+ # Single-char names are idiomatic for the tokenizer / loop indices (`i`, `j`, `n`, `c`).
30
+ many_single_char_names = "allow"
31
+ # Test-time DOM oracle: it deliberately doesn't document every panic/error path.
32
+ missing_panics_doc = "allow"
33
+ missing_errors_doc = "allow"
34
+ # A few config-ish structs legitimately carry several independent bool flags.
35
+ struct_excessive_bools = "allow"
36
+ # One large dispatch fn reads better intact than artificially split.
37
+ too_many_lines = "allow"
38
+ # We intentionally pin the concrete FxHashMap hasher rather than generalize over hashers.
39
+ implicit_hasher = "allow"
40
+ # `f32::midpoint` rounds through f64, so `(a + b) / 2.0` -> `a.midpoint(b)` can move the
41
+ # last bit of layout / paint geometry; outputs are pinned byte for byte, keep the f32 math.
42
+ manual_midpoint = "allow"
5
43
 
6
44
  [package]
7
45
  name = "turbo-dom-parser"
8
- version = "0.5.8"
46
+ version = "0.7.0"
9
47
  edition = "2021"
10
48
  description = "html5ever-backed native parser for turbo-dom (Layer 1)"
49
+ # Ships to npm only (napi/wasm addon); never published to crates.io.
50
+ publish = false
11
51
 
12
52
  [lib]
13
53
  crate-type = ["cdylib", "rlib"]
@@ -27,6 +67,10 @@ wasm-bindgen = { version = "0.2", optional = true }
27
67
  serde = { version = "1", features = ["derive"], optional = true }
28
68
  serde-wasm-bindgen = { version = "0.6", optional = true }
29
69
 
70
+ [dev-dependencies]
71
+ # The Chrome 156 control fixture (src/core/conformance_tests.rs).
72
+ serde_json = "1"
73
+
30
74
  [build-dependencies]
31
75
  napi-build = { version = "2", optional = true }
32
76
 
@@ -69,3 +113,9 @@ opt-level = 3
69
113
  [profile.profiling]
70
114
  inherits = "release"
71
115
  debug = true
116
+
117
+ # Pre-publish only: resolve the generated turbo-dom-v8 fork from its candidate branch
118
+ # (crates/turbo-dom-v8/scripts/push-candidate.sh). Remove once turbo-dom-v8 150.4.0 is
119
+ # on crates.io (see PUBLISHING.md).
120
+ [patch.crates-io]
121
+ turbo-dom-v8 = { git = "https://github.com/miaskiewicz/turbo-dom", rev = "e9b36c8733ef1cf1073f7053a8c5931381c978c5" }
package/README.md CHANGED
@@ -1,34 +1,55 @@
1
1
  # turbo-dom
2
2
 
3
- Release **0.5.8** (npm package, native parser and Rust DOM crate).
3
+ Release **0.7.0**. turbo-dom is the shared browser-engine base of three projects:
4
4
 
5
- A faster, more spec-correct DOM for test runners — a drop-in-style alternative to
6
- **jsdom** and **happy-dom** for **vitest** and **jest**.
5
+ - **[turbo-test](https://github.com/miaskiewicz/turbo-test)**, a vitest-compatible test runner. It runs tests in V8 over this repository's DOM.
6
+ - **[turbo-html2pdf](https://github.com/miaskiewicz/turbo-html2pdf)**, which renders HTML to PDF and uses this repository's layout engine.
7
+ - **[turbo-surf](https://github.com/miaskiewicz/turbo-surf)**, a headless browser that uses the DOM, the V8 binding, the layout engine and the screenshot painter.
7
8
 
8
- The HTML parser is native ([html5ever](https://github.com/servo/html5ever), Servo's
9
- WHATWG tree constructor, via Rust/N-API with a WASM fallback). The DOM itself stays in
10
- JavaScript but is **lazy** — nodes inflate from a compact typed-array buffer only when a
11
- test touches them, and `window` globals materialize only on first use.
9
+ turbo-dom is also a standalone npm package: a fast, spec-correct DOM environment for
10
+ **vitest** and **jest**, usable in place of jsdom or happy-dom.
11
+
12
+ Up to 0.5.x, each consumer kept its own copy of these pieces. Since 0.6.0 each piece lives
13
+ in this repository once and is published from here. The consumers depend on the
14
+ published crates and keep no copies.
15
+ [docs/CONSOLIDATION.md](docs/CONSOLIDATION.md) has the architecture, the dependency graph
16
+ and the publish order.
17
+
18
+ ## What is published
19
+
20
+ | package | registry | version | what it is | used by |
21
+ |---|---|---|---|---|
22
+ | [`@miaskiewicz/turbo-dom`](https://www.npmjs.com/package/@miaskiewicz/turbo-dom) | npm | 0.7.0 | lazy JS DOM runtime + native html5ever parser (N-API, WASM fallback) | vitest / jest users |
23
+ | [`turbo-dom`](crates/turbo-dom) | crates.io | 0.7.0 | pure-Rust DOM (`rtdom`), the one selector engine, CSSOM, partial cascade | all three consumers |
24
+ | [`turbo-dom-layout`](crates/turbo-dom-layout) | crates.io | 0.7.0 | HTML/CSS layout (block / inline / flex / grid / table), fonts, images, CSSOM-View geometry | turbo-html2pdf, turbo-surf |
25
+ | [`turbo-dom-raster`](crates/turbo-dom-raster) | crates.io | 0.7.0 | synthetic screenshots (PNG / SVG), canvas op-log replay, WebGL readback | turbo-surf |
26
+ | [`turbo-dom-browser-env`](crates/turbo-dom-browser-env) | crates.io | 0.7.0 | the `rtdom` <-> V8 browser environment (window, document, CSSOM, forms, events) | turbo-test, turbo-surf |
27
+ | [`turbo-dom-v8`](crates/turbo-dom-v8) | crates.io | 150.4.0 | turbo-dom's rusty_v8 fork, generated from the upstream crate plus reviewed patches | turbo-dom-browser-env, turbo-surf |
28
+
29
+ The root `turbo-dom-parser` crate is `publish = false`. It ships inside the npm package.
30
+
31
+ ```
32
+ turbo-dom ──► turbo-dom-layout ──► turbo-dom-raster
33
+ │ │ │
34
+ │ ├──► turbo-html2pdf │
35
+ │ └────────────────────┴──► turbo-surf
36
+ └──► turbo-dom-browser-env ◄── turbo-dom-v8
37
+ ├──► turbo-test
38
+ └──► turbo-surf
39
+ ```
40
+
41
+ ## Install
42
+
43
+ ### npm (vitest / jest)
12
44
 
13
45
  ```bash
14
46
  npm install -D @miaskiewicz/turbo-dom
15
47
  ```
16
48
 
17
- - ✅ **More compatible than happy-dom** — 99.72% on html5lib-tests vs happy-dom's 37%.
18
- Runs React Testing Library, `user-event`, downshift, Radix UI, and Headless UI unmodified.
19
- - ⚡ **Faster than both** — ~130× jsdom / ~45× happy-dom on a realistic suite (parse-memoized repeated shells), ~8–44× faster HTML parsing on real pages, and ~2.7× happy-dom on repeated queries while staying 99.7% spec-correct.
20
- - 🎨 **Real computed style** — `getComputedStyle` runs a **partial cascade**: it resolves real
21
- injected `<style>` rules (emotion/MUI `.css-HASH{…}`) + inline styles with proper specificity/source
22
- order, **inherits the standard inheritable properties down the tree** (a global `body{color}` reaches
23
- descendants), and **canonicalizes colors to `rgb()/rgba()`** like a browser (so
24
- `toHaveStyle({color:'#fff'})` matches a `#ffffff`/`white`/`rgb(255,255,255)` rule). It only ever
25
- returns values a real rule set — never an invented one.
26
- - 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros and computed
27
- style never invents layout. Geometry tests belong in a real browser.
28
-
29
- ## Quick start
49
+ Prebuilt native binaries are bundled for linux x64 (glibc and musl), linux arm64,
50
+ macOS arm64 and windows x64. Other platforms use the WASM parser.
30
51
 
31
- ### vitest
52
+ **vitest** (1–4):
32
53
 
33
54
  ```ts
34
55
  // vitest.config.ts
@@ -38,255 +59,203 @@ import { createRequire } from 'node:module';
38
59
  const envPath = createRequire(import.meta.url).resolve('@miaskiewicz/turbo-dom/environment/vitest');
39
60
 
40
61
  export default defineConfig({
41
- test: {
42
- environment: envPath, // vitest resolves a bare name only for `vitest-environment-*`
43
- // packages, so a scoped package is referenced by file path
44
- },
62
+ test: { environment: envPath }, // a scoped package is referenced by file path
45
63
  });
46
64
  ```
47
65
 
48
- Works on vitest 1–4.
49
-
50
- ### jest
66
+ **jest**:
51
67
 
52
68
  ```js
53
69
  // jest.config.js
54
- module.exports = {
55
- testEnvironment: '@miaskiewicz/turbo-dom/jest',
56
- };
70
+ module.exports = { testEnvironment: '@miaskiewicz/turbo-dom/jest' };
57
71
  ```
58
72
 
59
- Now `document`, `window`, and friends are global in your tests — write them exactly like
60
- you would against jsdom/happy-dom:
61
-
62
- ```js
63
- import { render, screen } from '@testing-library/react';
64
- import userEvent from '@testing-library/user-event';
73
+ `document` and `window` are then global, as with jsdom. React Testing Library,
74
+ `user-event`, downshift, Radix UI and Headless UI run unmodified.
65
75
 
66
- test('counter increments', async () => {
67
- render(<Counter />);
68
- await userEvent.click(screen.getByRole('button'));
69
- expect(screen.getByText('count: 1')).toBeInTheDocument();
70
- });
71
- ```
72
-
73
- ### Without a test runner
76
+ **Without a test runner:**
74
77
 
75
78
  ```js
76
79
  import { createEnvironment } from '@miaskiewicz/turbo-dom/runtime';
77
80
 
78
81
  const env = createEnvironment('<!doctype html><body><div id="app"></div></body>');
79
82
  env.document.querySelector('#app'); // nodes inflate lazily from the parse buffer
80
- env.window.localStorage; // globals materialize on first touch
81
83
  env.reset(); // fast per-file reset (reuses the parse buffer)
82
84
  ```
83
85
 
84
- ### Just the parser
86
+ **Just the parser:**
85
87
 
86
88
  ```js
87
89
  const { parse, parseBuffer, parseFragment } = require('@miaskiewicz/turbo-dom');
88
-
89
- parse('<div id=a><span>hi</span></div>'); // nested tree
90
- parseBuffer('<div id=a>...</div>'); // compact SoA typed-array buffer
91
- parseFragment('<rect/>', 'svg path'); // fragment in a context element
90
+ parse('<div id=a><span>hi</span></div>'); // nested tree
91
+ parseBuffer('<div id=a>...</div>'); // compact SoA typed-array buffer
92
+ parseFragment('<rect/>', 'svg path'); // fragment in a context element
92
93
  ```
93
94
 
94
- ### Choosing the parser backend (native vs WASM)
95
+ **Parser backend.** The default is the native addon, with WASM as the fallback. To force
96
+ one backend, use `createEnvironment(html, { parser: 'wasm' | 'native' | 'auto' })`,
97
+ `setParserMode('wasm')` or `TURBO_DOM_PARSER=wasm`.
95
98
 
96
- The runtime resolves a parser lazily on first parse: native N-API addon first, WASM
97
- fallback. Force it per environment or globally:
99
+ To run without Node builtins (bare V8): instantiate the `--target web` build from
100
+ `@miaskiewicz/turbo-dom/parser-wasm` yourself. Pass the binding to `setParser({ parse,
101
+ parseBuffer, parseFragment })` or set it as `globalThis.__TURBO_DOM_PARSER__`. Then call
102
+ `installGlobals(globalThis, { html })` from `@miaskiewicz/turbo-dom/install`.
98
103
 
99
- ```js
100
- import { createEnvironment, setParserMode } from '@miaskiewicz/turbo-dom/runtime';
104
+ ### Rust crates
101
105
 
102
- createEnvironment(html, { parser: 'wasm' }); // 'wasm' | 'native' | 'auto' (default)
103
- setParserMode('wasm'); // process-global; also TURBO_DOM_PARSER=wasm
106
+ ```toml
107
+ [dependencies]
108
+ turbo-dom = "0.7"
109
+ turbo-dom-layout = "0.7" # bundled fonts on by default
110
+ turbo-dom-raster = "0.7"
111
+ turbo-dom-browser-env = "0.7" # needs a V8 archive, see its README
104
112
  ```
105
113
 
106
- ### Embedding in a non-Node runtime (bare V8, no Node builtins)
107
-
108
- The native addon and the `--target nodejs` WASM build both need Node. For a fully
109
- node-free host, instantiate the `--target web` WASM yourself and inject the binding —
110
- the runtime then never touches `node:module`/`fs`:
111
-
112
- ```js
113
- import init, { initSync, parse, parseBuffer, parseFragment } from '@miaskiewicz/turbo-dom/parser-wasm';
114
- import { setParser, createEnvironment } from '@miaskiewicz/turbo-dom/runtime';
114
+ ```rust
115
+ use turbo_dom::Dom;
116
+ use turbo_dom::rtdom::cascade;
115
117
 
116
- initSync({ module: wasmBytes }); // you supply the bytes (sync, no fs)
117
- setParser({ parse, parseBuffer, parseFragment });
118
- createEnvironment('<div id=app/>'); // now runs with zero Node deps
118
+ let dom = Dom::parse("<main class=grid><div class=card id=hero>hi</div></main>");
119
+ let cards = dom.tree.query_selector_all("div.card"); // version-cached
120
+ let id = dom.tree.get_attribute(cards[0], "id"); // Some("hero")
121
+ let style = cascade::computed_style(&dom.tree, cards[0]); // partial, honest cascade
119
122
  ```
120
123
 
121
- `setParser` also reads `globalThis.__TURBO_DOM_PARSER__` if you prefer injection by
122
- global. Use `installGlobals(globalThis, { html })` (from `@miaskiewicz/turbo-dom/install`)
123
- to set up `document`/`window` on any global object.
124
+ Each crate's README covers its API: [turbo-dom](crates/turbo-dom/README.md),
125
+ [turbo-dom-layout](crates/turbo-dom-layout/README.md),
126
+ [turbo-dom-raster](crates/turbo-dom-raster/README.md),
127
+ [turbo-dom-browser-env](crates/turbo-dom-browser-env/README.md),
128
+ [turbo-dom-v8](crates/turbo-dom-v8/README.md). MSRV: Rust 1.89 for turbo-dom, -layout and
129
+ -raster; Rust 1.91 for turbo-dom-v8 and -browser-env.
124
130
 
125
- ## Compatibility
131
+ ## Feature flags
126
132
 
127
- | | turbo-dom | happy-dom | jsdom |
133
+ | crate | feature | default | effect |
128
134
  |---|---|---|---|
129
- | html5lib-tests conformance | **99.72%** | 37.35% | 97.03% |
130
- | @testing-library/dom + user-event | ✅ | ✅ | ✅ |
131
- | React + Radix / Headless UI / downshift | ✅ | partial | ✅ |
132
- | Real layout | ❌ (honest stub) | partial | partial |
133
- | `getComputedStyle` cascade | partial (real `<style>` + inline + inheritance + `rgb()` colors) | partial | partial |
134
- | Shadow DOM (attach, slots, event retargeting, scoped/`:host` CSS) | ✅ | ✅ | ✅ |
135
-
136
- turbo-dom inherits Servo's tree constructor, so the "messy input" cases hand-rolled parsers
137
- get wrong — adoption-agency reparenting (`<a><p></a></p>`), table foster-parenting, optional
138
- end tags, `<template>` content, SVG/MathML — all match the spec. The 5 remaining
139
- conformance misses are bleeding-edge `<select>`-family proposals upstream `html5ever` hasn't
140
- adopted yet.
141
-
142
- ## Performance
143
-
144
- Measured on darwin-arm64, Node 24 (`npm run bench:all`). Higher = faster, except
145
- the suite row (ms/file, lower = faster):
146
-
147
- | benchmark | turbo-dom | happy-dom | jsdom |
148
- |---|---:|---:|---:|
149
- | **realistic suite**, 200 files (ms/file) | **~0.025** | 1.08 | 3.20 |
150
- | **cold per-file construct + query** (ops/s) | **~230k** | 575 | 267 |
151
- | **parse 56 KB SSR** (ops/s) | **528** | 48 | 65 |
152
- | **parse 20 KB real page** (ops/s) | **4,353** | 135 | 99 |
153
- | repeated query throughput (iters/s) | **~1.8M** | 661k | 3.3k |
154
- | html5lib conformance | **99.72%** | 37.35% | 97.03% |
155
-
156
- On a realistic suite — 200 files of construct + queries + events — turbo-dom is
157
- **~45× happy-dom and ~130× jsdom**, runs repeated queries **~2.7× happy-dom**
158
- (~550× jsdom), and parses real pages/SSR documents **~8–44×** faster, all at 99.7%
159
- conformance.
160
-
161
- The per-file setup number is so high because the parser **memoizes the read-only
162
- SoA buffer by HTML string**: a suite calls the env setup with the same document
163
- shell every file, so it's parsed once and the buffer (never mutated — all changes
164
- go to per-Document overlays) is reused. The first parse of a given shell pays full
165
- cost (the parse rows above); every reuse is near-free.
166
-
167
- **turbo-dom wins across the board on what test suites actually do**: per-file
168
- construction (~45× happy-dom, ~130× jsdom on a repeated-shell suite), parsing,
169
- spec-correctness (99.7% vs 37%), **and** repeated queries.
170
-
171
- How the query speed holds up against happy-dom (whose whole design trades correctness
172
- for query speed): the selector/match engine is allocation-free on the hot paths (no
173
- per-element `classList`/`split`/regex), and `querySelectorAll`/`getElementsBy*`/
174
- `getElementById` results are **cached per (selector, DOM-version)** — a static
175
- `querySelectorAll` list is safe to reuse until the next mutation. So the repeated
176
- queries RTL/`findBy`/`waitFor` run against an unchanged tree are near-free, and
177
- `getByLabelText` went from O(n²) (1.3 ms) to ~270 µs.
178
-
179
- ## How it works
180
-
181
- ```
182
- test code (RTL, user-event)
183
- └─ lazy window (Proxy, self-replacing globals) ← JS
184
- └─ lazy copy-on-write node tree (memoized identity) ← JS
185
- └─ immutable Structure-of-Arrays parse buffer ← shared
186
- └─ Rust: html5ever → flat typed-array buffer ← native (N-API / WASM)
187
- ```
188
-
189
- The parser runs in Rust (compute-bound, one boundary crossing per parse). The DOM stays in
190
- JS (chatty, fine-grained) but pays only for what a test touches. Full design notes:
191
- [turbo-dom-spec.md](./turbo-dom-spec.md).
192
-
193
- ## Rust-native DOM runtime (`rtdom`) — for Rust consumers
194
-
195
- Everything above is the **JS-consumer** path: the DOM is JS objects, so React/RTL touch it with
196
- zero boundary — that's why it's fast for vitest/jest. A **Rust** consumer (crawler, extractor,
197
- server-side scraper) wants the opposite: the DOM in-process in Rust, no JS at all. For that there's
198
- **`rtdom`** — a pure-Rust port of the runtime (lazy copy-on-write tree over the same SoA buffer,
199
- version-cached queries, partial `getComputedStyle`, events, shadow DOM, serialize).
135
+ | `turbo-dom-layout` | `bundled-fonts` | on | embeds OFL faces (Inter/Roboto, Liberation Serif/PT Serif, Fira Code/IBM Plex Mono) so a document lays out with no caller fonts |
136
+ | | `hyphenation` | on | `hyphens: auto` for English (`en` / `en-*`, not en-GB) with the embedded en-US TeX patterns (~90 KB); off, `auto` acts as `manual` |
137
+ | | `svg` | off | routes `image/svg+xml` through the SVG rasterizer in the image pipeline (implies `svg-rasterize`) |
138
+ | | `svg-rasterize` | off | the shared resvg rasterizer only (`svg::rasterize`) |
139
+ | | `serde` | off | `Serialize` for the CSSOM-View metrics records |
140
+ | | `perf` | off | hot-path instrumentation counters |
141
+ | | `test-support` | off | font fixtures and test literals for downstream tests; never in production |
142
+ | `turbo-dom-raster` | `avif` | off | AVIF pixels (pure-Rust rav1d) |
143
+ | | `jxl` | off | JPEG XL pixels (jxl-oxide) |
144
+ | | `gpu-metal` | off | wgpu (Metal) canvas and WebGL rasterization, falling back to the CPU path on any GPU failure |
145
+ | | `coretext` | off | macOS only: canvas text through CoreText (inert elsewhere) |
146
+ | `turbo-dom-browser-env` | `host-internals` | off | doc-hidden host access to native documents (turbo-surf) |
147
+ | | `test-support` | off | probe, control and case constants for embedders' Chrome-parity tests |
148
+ | | `native-sdk-frame-read` | off | reads the interceptor's should-throw mode from the V8 frame, for native SDKs without the fork fix |
149
+ | `turbo-dom-parser` (npm) | `napi-bind` / `wasm-bind` / `wasm-runtime` | `napi-bind` | the native addon, the WASM parser and the WASM DOM-runtime spike |
150
+
151
+ `turbo-dom` and `turbo-dom-v8` have no features of their own. `turbo-dom-v8` forwards
152
+ upstream rusty_v8's features (for example `simdutf`).
153
+
154
+ ## Status
155
+
156
+ - **Parser:** html5lib-tests conformance is 99.94% through the npm parser (happy-dom
157
+ 37.35%, jsdom 97.03%). The remaining misses are `<select>`-family proposals that
158
+ upstream html5ever has not adopted yet. The Rust `rtdom` tree passes its direct
159
+ html5lib-tests gate at 100% (`npm run conformance:rtdom`).
160
+ - **Chrome parity:** behaviour fixes are backed by Chrome 156 controls. These are
161
+ fixtures recorded from headless Chrome and asserted in tests. Every corpus output change
162
+ is declared in [`tools/parity/expected/`](tools/parity/expected) (checkpoint `b-0.7.0`).
163
+ 0.7.0 adds vertical writing modes, UAX #9 bidi, Chrome's line box model on the PDF path,
164
+ the full `border-radius` model with `overflow` clipping, background tiling and gradient
165
+ length stops, native form-control appearance, size / layout containment, replaced media
166
+ boxes, XML documents (with DTD ATTLIST defaults), processing instructions, CSS
167
+ animations in `getAnimations()`, a browser-env cascade with specificity, `@layer`,
168
+ `@supports` and `@scope`, real canvas `measureText` and an opt-in V8 startup snapshot
169
+ install (see [CHANGELOG.md](CHANGELOG.md)).
170
+ - **Open gaps (tracked as issues):**
171
+ - glyph stems in raster screenshots lack Chrome's stem darkening, so glyph edges are
172
+ up to ~0.5px thinner per side (#266).
173
+ - Known difference, not tracked: `hyphens: auto` covers English only, and Chrome on
174
+ macOS hyphenates with the system dictionary, whose points differ for some words.
175
+ - **The npm runtime has no layout.** Geometry is synthetic: `getBoundingClientRect()`,
176
+ `offsetWidth/Height`, `clientWidth/Height` and `scrollWidth/Height` return a non-zero
177
+ size that is stable for a given DOM state (blocks fill the parent's width, the root
178
+ takes `innerWidth` or 1024; heights stack children or 18px per line), while every
179
+ position (`top` / `left`, `offsetTop/Left`, `scrollTop/Left`) is 0.
180
+ `getComputedStyle` is a partial cascade: top-level `<style>` rules (and rules injected
181
+ with `insertRule`) plus inline styles, ordered by Selectors 4 specificity and source
182
+ order, with a fixed set of inherited properties. Colours read back as `rgb()` /
183
+ `rgba()` and a bare `0` as `0px`; a property no rule sets reads `''` (never an initial
184
+ value). Not resolved: rules inside any at-rule block (`@media`, `@supports`, `@layer`,
185
+ `@container`, `@scope`) are skipped, rules with state pseudo-classes (`:hover`, ...)
186
+ never match, a pseudo-element argument is ignored (the element's own style comes
187
+ back) and `::before`-style rules never apply. Values come back as written: no unit
188
+ conversion (`2em` stays `2em`), and `var(...)` and `inherit` / `initial` / `unset` are
189
+ returned verbatim, not resolved. Shadow DOM is supported: `attachShadow`, declarative
190
+ shadow roots, slots and `assignedNodes()`, event retargeting, scoped styles with
191
+ `:host` / `::slotted()`, and inheritance across the host boundary. Layout belongs to
192
+ `turbo-dom-layout`, and through it to turbo-html2pdf and turbo-surf.
193
+
194
+ ## Benchmarks — pending re-run
195
+
196
+ 0.6.0 and 0.7.0 have not been benchmarked against jsdom / happy-dom yet. All four
197
+ repositories will be re-run together on a quiet machine. Until then, the last measured
198
+ numbers (v0.1.55, 2026-06-07, darwin-arm64, JS runtime vs happy-dom and jsdom) are kept
199
+ unchanged and labelled in [docs/BENCHMARKS-history.md](docs/BENCHMARKS-history.md). They
200
+ predate the consolidation. 0.7.0's browser-env performance work (measured on a real
201
+ React/MUI suite) is listed in [CHANGELOG.md](CHANGELOG.md).
200
202
 
201
- Why a separate runtime instead of exposing this one to Rust via WASM? Measured: a Rust-DOM-in-WASM
202
- called *from JS* is **~0.55×** the JS runtime (the boundary crossing dominates — exactly what the
203
- [spec §3](./turbo-dom-spec.md) predicted), while `rtdom` run **in-process from Rust** is **~2.7×**
204
- the JS runtime on the same chatty workload (zero boundary). So: JS consumers keep the JS runtime,
205
- Rust consumers use `rtdom`.
203
+ ## Development
206
204
 
207
- Add it to a Rust project from crates.io (the `turbo-dom` crate is the Rust-native
208
- runtime; the npm `@miaskiewicz/turbo-dom` is the JS path):
205
+ Requires Node ≥ 24 and Rust 1.98.0 (CI's toolchain; `mise.toml` pins Node and Rust).
206
+ `npm install` also points git at `.githooks`, whose pre-commit hook runs `npm run test:cov`
207
+ whenever `src/`, `test/` or `harness/` changes.
209
208
 
210
209
  ```bash
211
- cargo add turbo-dom
210
+ npm run build # native addon + both WASM packages
211
+ npm test # JS suite (unit, conformance, differential, gauntlets)
212
+ npm run test:cov # same + coverage gate (lines 100 / functions 100 / branches 97.3)
213
+ npm run test:vitest && npm run test:jest
214
+ npm run test:rust # cargo test -p turbo-dom
215
+ npm run conformance # html5lib-tests report (parser)
216
+ npm run conformance:rtdom # html5lib-tests through the rtdom tree
217
+ cargo test -p turbo-dom-layout # + --features svg / perf / serde,test-support
218
+ cargo test -p turbo-dom-raster # + --features avif / jxl
219
+ python3 crates/turbo-dom-v8/scripts/generate.py --download --write # generate the V8 fork
220
+ cargo test -p turbo-dom-browser-env --features host-internals,test-support # needs RUSTY_V8_ARCHIVE
221
+ npm run complexity:rust # cc-check --max 15 (install: CLAUDE.md)
222
+ npm run complexity:js # oxlint complexity <= 15 over src/ + crates/turbo-dom-browser-env/src
212
223
  ```
213
224
 
214
- ```rust
215
- use turbo_dom::{Dom, DocumentExt};
216
- use turbo_dom::rtdom::cascade;
225
+ [CLAUDE.md](CLAUDE.md) has the complete command list and the design rules.
217
226
 
218
- let mut dom = Dom::parse("<main class=grid><div class=card id=hero>hi</div></main>");
219
- let cards = dom.tree.query_selector_all("div.card"); // version-cached, in-process
220
- let id = dom.tree.get_attribute(cards[0], "id"); // Some("hero") — plain Rust call
221
- let style = cascade::computed_style(&dom.tree, cards[0]); // partial honest cascade
222
- ```
227
+ **Test fonts come from fixtures.** Text-geometry tests load the repository's own font
228
+ files (`turbo_dom_layout::test_support::fixture_fonts()`, from
229
+ `crates/turbo-dom-layout/assets/fonts`). Their expected values come from Chrome 156 with
230
+ the same files loaded through `@font-face` (`scripts/fixture-font-chrome-controls.mjs`).
231
+ Results therefore do not depend on the fonts installed on the host. Only the tests of
232
+ the host-font policy itself read system fonts.
223
233
 
224
- - **crates.io:** [`turbo-dom`](https://crates.io/crates/turbo-dom) — the Rust DOM engine lives
225
- here and only here (the workspace-member crate `crates/turbo-dom/`; there is no in-repo
226
- `src/rtdom` copy and no `rust-runtime` cargo feature — an earlier feature gated a duplicate copy
227
- that was consolidated away). Minimal deps (`html5ever` + `rustc-hash`, no napi/wasm), a runnable
228
- `examples/crawl.rs`, 227 tests, 100% line coverage, and a direct html5lib-tests gate at
229
- **99.75%**. From the repo: `npm run build:rtdom` (= `cargo build -p turbo-dom --release`),
230
- `npm run test:rust` (= `cargo test -p turbo-dom`), `npm run conformance:rtdom`.
231
-
232
- ## Limitations (by design)
233
-
234
- - **No layout.** `getBoundingClientRect()` returns zeros; `getClientRects()` is empty.
235
- - **`getComputedStyle` is a partial cascade** — it resolves REAL injected `<style>` rules
236
- (emotion/MUI `.css-HASH{…}`) plus inline `style`, applying specificity and source order
237
- (inline wins). It **inherits the standard inheritable properties** (`color`, `font*`,
238
- `line-height`, `text-align`, `visibility`, …) down the flattened tree — a global rule on
239
- `body`/`:root` reaches descendants, and inheritance crosses the shadow host boundary. It
240
- **canonicalizes `<color>` values to `rgb()/rgba()`** (`#fff`/`white`/`hsl(...)` → `rgb(255, 255, 255)`)
241
- exactly as browsers serialize computed style, so `@testing-library/jest-dom`'s `toHaveStyle`
242
- color assertions compare equal regardless of how the rule authored the color. It expands common
243
- shorthands to longhands (`margin`/`padding`/`border`/single-token `background`), serializes bare
244
- `0` as `0px` for length props, and normalizes `font-family` comma spacing. Out of scope (returns
245
- `''`): `@media`/`@supports`/`@keyframes`, `:hover`/state pseudo-classes, pseudo-elements, the
246
- `inherit`/`initial`/`unset` keywords, CSS custom properties, and length-unit conversion
247
- (`em`/`rem`→`px`). Only ever returns values from a matched, inline, or inherited declaration —
248
- never an invented initial value. Style/geometry assertions belong in a real browser
249
- (Playwright/WebDriver).
250
- - **`<style>.textContent` reflects rules injected via `sheet.insertRule()`** — CSS-in-JS engines in
251
- "speedy" mode (emotion/styled-components) inject rules straight into the CSSOM without writing the
252
- node's text; turbo-dom serializes them back into `textContent` (as browsers/jsdom do), so tests
253
- that scrape `querySelectorAll('style')` text see the injected CSS.
254
- - Canvas, `<select>` rendering, and similar visual APIs are honest no-op stubs.
255
- - **Shadow DOM** is supported and pay-for-what-you-use — every event/query/cascade hot path
256
- is unchanged until the first `attachShadow` flips a per-document flag. Covered: `attachShadow`
257
- (open/closed), encapsulated `querySelector`/`getElementById`, `getRootNode({composed})`,
258
- full event propagation with `target`/`relatedTarget` retargeting and `composed` boundary
259
- crossing, `<slot>` `assignedNodes`/`assignedElements`/`assignedSlot`, scoped `getComputedStyle`
260
- with `:host`/`:host(...)`/`::slotted(...)` and inheritance across the boundary, and declarative
261
- `<template shadowrootmode>` promotion. Out of scope (honest): flattened-tree layout, `slotchange`
262
- events, and the cascade caveats above (`@media`/state/pseudo-elements) inside shadow trees.
234
+ ### Gates
263
235
 
264
- ## Development
236
+ | gate | where | status |
237
+ |---|---|---|
238
+ | line coverage (tarpaulin, LLVM) | `ci.yml` `coverage` | **blocking**. Floors: turbo-dom 100, turbo-dom-parser `src/core.rs` 100, turbo-dom-layout 99.8, turbo-dom-raster 100 (default / avif / jxl), turbo-dom-browser-env 97.6 |
239
+ | JS runtime coverage | `npm run test:cov` (pre-commit hook) | lines 100 / functions 100 / branches 97.3 |
240
+ | cyclomatic complexity ≤ 15 (Rust cc-check, JS oxlint) | `ci.yml` `complexity-rust`, `complexity-js` | **report-only** (`continue-on-error`) until the post-release refactor clears the backlog ([docs/CONSOLIDATION.md](docs/CONSOLIDATION.md#complexity)) |
241
+ | clippy pedantic `-D warnings`, rustfmt, MSRV 1.89, version agreement, churn allocation gate, verify-copied reviews | `ci.yml` | blocking |
265
242
 
266
- Requires Node ≥ 24 and a Rust toolchain (`rustup`, stable). `mise.toml` pins both.
243
+ **CI runners.** Pull requests and pushes to `main` run on Linux (x64 and arm64) and on
244
+ Windows x64: the napi build, and the V8 / browser-env matrix with the default and
245
+ simdutf archives. macOS arm64 builds and jobs run only in the release workflow.
246
+ `publish.yml` calls `ci.yml` with `darwin: true`. Linux binaries build on ubuntu-22.04 and a
247
+ guard (`scripts/check-glibc-ceiling.sh`) rejects any addon that needs glibc above 2.35. The
248
+ guard also fails when an expected addon is missing or unreadable. A newer push to a pull
249
+ request cancels its superseded CI run. `main`, tag and publish runs each get their own
250
+ concurrency group, so they are never cancelled or dropped while pending.
267
251
 
268
- ```bash
269
- npm install
270
- npm run build # native addon (.node) + wasm — the JS-consumer artifacts
271
- npm test # JS suite (unit, conformance, differential, gauntlets)
272
- npm run test:rust # Rust tests: parser core + the turbo-dom crate (cargo test -p turbo-dom)
273
- npm run conformance # html5lib-tests report (parser)
274
- npm run conformance:rtdom # html5lib-tests gate run through the rtdom tree
275
- npm run bench:all # JS-runtime benchmarks
276
- npm run build:wasm # wasm32 parser fallback
277
- npm run build:rtdom # pure-Rust DOM runtime (no napi/wasm) — the Rust-consumer build
278
- ```
252
+ ## Releasing
279
253
 
280
- The JS runtime (`src/runtime/*.mjs`) and the Rust runtime (the `crates/turbo-dom` crate) are
281
- independent — touching one never affects the other. See
282
- [RUST_PORT_PLAN.md](./RUST_PORT_PLAN.md) for the dual-runtime architecture and
283
- [RUST_PORT_PERF_HISTORY.md](./RUST_PORT_PERF_HISTORY.md) for how each JS perf win maps to Rust.
284
-
285
- Contributions welcome — issues and PRs at
286
- [github.com/miaskiewicz/turbo-dom](https://github.com/miaskiewicz/turbo-dom).
254
+ One annotated `vX.Y.Z` tag on the release merge commit publishes everything in dependency
255
+ order. The order is turbo-dom-v8 → turbo-dom → turbo-dom-layout → turbo-dom-raster →
256
+ turbo-dom-browser-env → npm. See [PUBLISHING.md](PUBLISHING.md) and the history in
257
+ [CHANGELOG.md](CHANGELOG.md).
287
258
 
288
259
  ## License
289
260
 
290
- [MIT](./LICENSE).
291
-
292
- Release preparation, merge-commit tagging and registry verification are documented in [PUBLISHING.md](./PUBLISHING.md).
261
+ [MIT](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.5.8",
3
+ "version": "0.7.0",
4
4
  "description": "Faster, more spec-correct DOM for test runners \u2014 native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
@@ -77,7 +77,7 @@
77
77
  "build:wasm:pkg": "wasm-pack build --target nodejs --out-dir pkg --no-default-features --features wasm-bind",
78
78
  "build:wasm:web": "wasm-pack build --target web --out-dir pkg-web --no-default-features --features wasm-bind",
79
79
  "test": "node --test 'test/*.mjs'",
80
- "test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/runtime/**' --test-coverage-lines=99 --test-coverage-functions=95 --test-coverage-branches=95 'test/*.mjs'",
80
+ "test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/runtime/**' --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=97.3 'test/*.mjs'",
81
81
  "test:rust": "cargo test -p turbo-dom",
82
82
  "test:all": "npm run build && npm run test:rust && npm test",
83
83
  "prepare": "git config core.hooksPath .githooks || true",
@@ -97,7 +97,9 @@
97
97
  "build:rtdom": "cargo build -p turbo-dom --release",
98
98
  "profile:churn": "cargo build --profile profiling --example churn_profile -p turbo-dom && echo 'built \u2014 CPU-profile with: ./target/profiling/examples/churn_profile & sample $! 20 -file target/churn.sample.txt'",
99
99
  "bench:compare": "node bench/dom-compare.mjs",
100
- "bench:compare:all": "RTDOM_JSON=\"$(cargo test -p turbo-dom --release rtdom::bench::compare_all -- --ignored --nocapture 2>/dev/null | sed -n 's/.*RTDOM_JSON //p')\" node --expose-gc --max-old-space-size=8192 bench/compare-all.mjs"
100
+ "bench:compare:all": "RTDOM_JSON=\"$(cargo test -p turbo-dom --release rtdom::bench::compare_all -- --ignored --nocapture 2>/dev/null | sed -n 's/.*RTDOM_JSON //p')\" node --expose-gc --max-old-space-size=8192 bench/compare-all.mjs",
101
+ "complexity:rust": "git ls-files -z '*.rs' | xargs -0 cc-check --max 15",
102
+ "complexity:js": "npx --yes oxlint@1.87.0 -c .oxlintrc.complexity.json src crates/turbo-dom-browser-env/src"
101
103
  },
102
104
  "keywords": [
103
105
  "dom",
package/pkg/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "turbo-dom-parser",
3
3
  "description": "html5ever-backed native parser for turbo-dom (Layer 1)",
4
- "version": "0.5.8",
4
+ "version": "0.7.0",
5
5
  "files": [
6
6
  "turbo_dom_parser_bg.wasm",
7
7
  "turbo_dom_parser.js",
Binary file