@miaskiewicz/turbo-dom 0.1.62 → 0.2.1
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/README.md +26 -12
- package/package.json +1 -1
- package/src/runtime/cascade.mjs +29 -17
- package/src/runtime/color.mjs +160 -0
- package/src/runtime/dom.mjs +28 -5
- package/src/runtime/window.mjs +45 -6
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/README.md
CHANGED
|
@@ -15,10 +15,14 @@ npm install -D @miaskiewicz/turbo-dom
|
|
|
15
15
|
- ✅ **More compatible than happy-dom** — 99.72% on html5lib-tests vs happy-dom's 37%.
|
|
16
16
|
Runs React Testing Library, `user-event`, downshift, Radix UI, and Headless UI unmodified.
|
|
17
17
|
- ⚡ **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.
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
- 🎨 **Real computed style** — `getComputedStyle` runs a **partial cascade**: it resolves real
|
|
19
|
+
injected `<style>` rules (emotion/MUI `.css-HASH{…}`) + inline styles with proper specificity/source
|
|
20
|
+
order, **inherits the standard inheritable properties down the tree** (a global `body{color}` reaches
|
|
21
|
+
descendants), and **canonicalizes colors to `rgb()/rgba()`** like a browser (so
|
|
22
|
+
`toHaveStyle({color:'#fff'})` matches a `#ffffff`/`white`/`rgb(255,255,255)` rule). It only ever
|
|
23
|
+
returns values a real rule set — never an invented one.
|
|
24
|
+
- 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros and computed
|
|
25
|
+
style never invents layout. Geometry tests belong in a real browser.
|
|
22
26
|
|
|
23
27
|
## Quick start
|
|
24
28
|
|
|
@@ -124,7 +128,7 @@ to set up `document`/`window` on any global object.
|
|
|
124
128
|
| @testing-library/dom + user-event | ✅ | ✅ | ✅ |
|
|
125
129
|
| React + Radix / Headless UI / downshift | ✅ | partial | ✅ |
|
|
126
130
|
| Real layout | ❌ (honest stub) | partial | partial |
|
|
127
|
-
| `getComputedStyle` cascade | partial (real `<style>` + inline) | partial | partial |
|
|
131
|
+
| `getComputedStyle` cascade | partial (real `<style>` + inline + inheritance + `rgb()` colors) | partial | partial |
|
|
128
132
|
| Shadow DOM (attach, slots, event retargeting, scoped/`:host` CSS) | ✅ | ✅ | ✅ |
|
|
129
133
|
|
|
130
134
|
turbo-dom inherits Servo's tree constructor, so the "messy input" cases hand-rolled parsers
|
|
@@ -189,13 +193,23 @@ JS (chatty, fine-grained) but pays only for what a test touches. Full design not
|
|
|
189
193
|
- **No layout.** `getBoundingClientRect()` returns zeros; `getClientRects()` is empty.
|
|
190
194
|
- **`getComputedStyle` is a partial cascade** — it resolves REAL injected `<style>` rules
|
|
191
195
|
(emotion/MUI `.css-HASH{…}`) plus inline `style`, applying specificity and source order
|
|
192
|
-
(inline wins). It
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
(inline wins). It **inherits the standard inheritable properties** (`color`, `font*`,
|
|
197
|
+
`line-height`, `text-align`, `visibility`, …) down the flattened tree — a global rule on
|
|
198
|
+
`body`/`:root` reaches descendants, and inheritance crosses the shadow host boundary. It
|
|
199
|
+
**canonicalizes `<color>` values to `rgb()/rgba()`** (`#fff`/`white`/`hsl(...)` → `rgb(255, 255, 255)`)
|
|
200
|
+
exactly as browsers serialize computed style, so `@testing-library/jest-dom`'s `toHaveStyle`
|
|
201
|
+
color assertions compare equal regardless of how the rule authored the color. It expands common
|
|
202
|
+
shorthands to longhands (`margin`/`padding`/`border`/single-token `background`), serializes bare
|
|
203
|
+
`0` as `0px` for length props, and normalizes `font-family` comma spacing. Out of scope (returns
|
|
204
|
+
`''`): `@media`/`@supports`/`@keyframes`, `:hover`/state pseudo-classes, pseudo-elements, the
|
|
205
|
+
`inherit`/`initial`/`unset` keywords, CSS custom properties, and length-unit conversion
|
|
206
|
+
(`em`/`rem`→`px`). Only ever returns values from a matched, inline, or inherited declaration —
|
|
207
|
+
never an invented initial value. Style/geometry assertions belong in a real browser
|
|
208
|
+
(Playwright/WebDriver).
|
|
209
|
+
- **`<style>.textContent` reflects rules injected via `sheet.insertRule()`** — CSS-in-JS engines in
|
|
210
|
+
"speedy" mode (emotion/styled-components) inject rules straight into the CSSOM without writing the
|
|
211
|
+
node's text; turbo-dom serializes them back into `textContent` (as browsers/jsdom do), so tests
|
|
212
|
+
that scrape `querySelectorAll('style')` text see the injected CSS.
|
|
199
213
|
- Canvas, `<select>` rendering, and similar visual APIs are honest no-op stubs.
|
|
200
214
|
- **Shadow DOM** is supported and pay-for-what-you-use — every event/query/cascade hot path
|
|
201
215
|
is unchanged until the first `attachShadow` flips a per-document flag. Covered: `attachShadow`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Faster, more spec-correct DOM for test runners — 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",
|
package/src/runtime/cascade.mjs
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
// added to any benchmarked path.
|
|
8
8
|
//
|
|
9
9
|
// HONEST: this only ever returns values that come from a REAL matched rule or inline
|
|
10
|
-
// style. A property no stylesheet/inline set
|
|
11
|
-
// layout/cascade numbers. Out of
|
|
12
|
-
// :hover & other stateful
|
|
13
|
-
//
|
|
10
|
+
// style (or inherited from one — see INHERITED). A property no stylesheet/inline set
|
|
11
|
+
// still reads '' — it never invents layout/cascade numbers or initial values. Out of
|
|
12
|
+
// scope (return ''): @media/@supports/@keyframes, :hover & other stateful
|
|
13
|
+
// pseudo-classes, pseudo-elements, the `inherit`/`initial`/`unset` keywords,
|
|
14
|
+
// CSS custom-property resolution. Colors ARE canonicalized to rgb()/rgba() (color.mjs)
|
|
15
|
+
// and bare-0 lengths to `0px`, matching what a browser serializes.
|
|
14
16
|
|
|
15
17
|
import { matchesSelector } from './selectors.mjs';
|
|
18
|
+
import { COLOR_PROPS, canonicalizeColor } from './color.mjs';
|
|
16
19
|
|
|
17
20
|
const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
18
21
|
|
|
@@ -162,9 +165,10 @@ function collectMatched(el, rules, kind, into) {
|
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
// Inheritable properties we propagate
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
+
// Inheritable properties we propagate down the flattened tree (curated; matches
|
|
169
|
+
// the common inherited set). Honest partial: only these inherit. Covers both
|
|
170
|
+
// light-DOM "CSS globals" (a rule on html/body cascades to descendants) and
|
|
171
|
+
// crossing the shadow host boundary into shadow content.
|
|
168
172
|
const INHERITED = new Set([
|
|
169
173
|
'color', 'cursor', 'direction', 'font', 'font-family', 'font-size', 'font-style',
|
|
170
174
|
'font-variant', 'font-weight', 'letter-spacing', 'line-height', 'list-style',
|
|
@@ -199,6 +203,9 @@ function lookup(map, prop) {
|
|
|
199
203
|
// (emotion minifies to "a",b,c). Scoped to font-family so we don't rewrite commas
|
|
200
204
|
// inside rgb()/cubic-bezier() values, which browsers leave compact.
|
|
201
205
|
if (prop === 'font-family') return v.replace(/\s*,\s*/g, ', ');
|
|
206
|
+
// <color> properties: browsers serialize computed colors to rgb()/rgba() —
|
|
207
|
+
// names, hex, hsl all canonicalize (includeNamed=true). Unrecognized → passthrough.
|
|
208
|
+
if (COLOR_PROPS.has(prop)) { const c = canonicalizeColor(v, true); if (c) return c; }
|
|
202
209
|
return v;
|
|
203
210
|
}
|
|
204
211
|
|
|
@@ -255,16 +262,21 @@ export function makeGetComputedStyle() {
|
|
|
255
262
|
const inline = el.getAttribute && el.getAttribute('style');
|
|
256
263
|
if (inline) parseDecls(inline, map);
|
|
257
264
|
|
|
258
|
-
// Inheritance
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
265
|
+
// Inheritance: an element inherits unset inheritable props from its flattened
|
|
266
|
+
// parent (DOM parent, hopping shadow-root→host at the boundary). This carries
|
|
267
|
+
// "CSS globals" (a rule on html/body/:root) down the tree AND propagates into
|
|
268
|
+
// shadow content. Honest partial: only the curated INHERITED set inherits, only
|
|
269
|
+
// from values a REAL rule/inline actually set (pv truthy) — never an initial
|
|
270
|
+
// value. Recursion is memoized per element/version and terminates at the root
|
|
271
|
+
// (flattenedParent → null). Test-time only; hot paths never call gcs.
|
|
272
|
+
// `inShadow` is unused now that light-DOM inherits too, but the flattened-parent
|
|
273
|
+
// hop still crosses the host boundary correctly for shadow content.
|
|
274
|
+
void inShadow;
|
|
275
|
+
const parent = flattenedParent(el);
|
|
276
|
+
if (parent) {
|
|
277
|
+
const ps = gcs(parent);
|
|
278
|
+
for (const prop of INHERITED) {
|
|
279
|
+
if (!map.has(prop)) { const pv = ps.getPropertyValue(prop); if (pv) map.set(prop, pv); }
|
|
268
280
|
}
|
|
269
281
|
}
|
|
270
282
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Color canonicalization — browsers serialize computed/inline <color> values to
|
|
2
|
+
// rgb()/rgba(), never as the authored `#fff`/`white`. testing-library's
|
|
3
|
+
// `toHaveStyle` compares the element's computed value against the expected value
|
|
4
|
+
// AS STRINGS, after each is run through the DOM's own normalization — so to match
|
|
5
|
+
// a browser (and jsdom) both getComputedStyle (cascade.mjs) and inline style
|
|
6
|
+
// read-back (dom.mjs styleGet) must canonicalize. This module is the shared core.
|
|
7
|
+
//
|
|
8
|
+
// PERF: only ever called from getComputedStyle + el.style reads (test/app time),
|
|
9
|
+
// and only for properties in COLOR_PROPS. Parse/query/match/event hot paths never
|
|
10
|
+
// touch it. Returns null when the value isn't a recognized color → caller keeps
|
|
11
|
+
// the original string (honest passthrough for url()/gradients/var()/keywords).
|
|
12
|
+
//
|
|
13
|
+
// Two modes, matching real browsers:
|
|
14
|
+
// includeNamed=false (inline el.style): hex + rgb()/hsl() canonicalize; NAMED
|
|
15
|
+
// keywords (`red`, `transparent`) stay as authored — Chrome keeps them inline.
|
|
16
|
+
// includeNamed=true (computed getComputedStyle): names resolve to rgb() too.
|
|
17
|
+
|
|
18
|
+
// Properties whose value is a <color> (longhands + the single-token shorthands we
|
|
19
|
+
// already expand). `background` is included for the bare-color MUI case
|
|
20
|
+
// (`background:#fff`); a non-color background value just fails to parse → passthrough.
|
|
21
|
+
export const COLOR_PROPS = new Set([
|
|
22
|
+
'color', 'background-color', 'background', 'border-color',
|
|
23
|
+
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
24
|
+
'outline-color', 'text-decoration-color', 'column-rule-color', 'caret-color',
|
|
25
|
+
'fill', 'stroke', 'stop-color', 'flood-color', 'lighting-color',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// CSS named colors → #rrggbb (reused through hexToRgb). `transparent` is special
|
|
29
|
+
// (→ rgba(0,0,0,0)); both gate behind includeNamed.
|
|
30
|
+
const NAMED = {
|
|
31
|
+
aliceblue: 'f0f8ff', antiquewhite: 'faebd7', aqua: '00ffff', aquamarine: '7fffd4',
|
|
32
|
+
azure: 'f0ffff', beige: 'f5f5dc', bisque: 'ffe4c4', black: '000000',
|
|
33
|
+
blanchedalmond: 'ffebcd', blue: '0000ff', blueviolet: '8a2be2', brown: 'a52a2a',
|
|
34
|
+
burlywood: 'deb887', cadetblue: '5f9ea0', chartreuse: '7fff00', chocolate: 'd2691e',
|
|
35
|
+
coral: 'ff7f50', cornflowerblue: '6495ed', cornsilk: 'fff8dc', crimson: 'dc143c',
|
|
36
|
+
cyan: '00ffff', darkblue: '00008b', darkcyan: '008b8b', darkgoldenrod: 'b8860b',
|
|
37
|
+
darkgray: 'a9a9a9', darkgreen: '006400', darkgrey: 'a9a9a9', darkkhaki: 'bdb76b',
|
|
38
|
+
darkmagenta: '8b008b', darkolivegreen: '556b2f', darkorange: 'ff8c00', darkorchid: '9932cc',
|
|
39
|
+
darkred: '8b0000', darksalmon: 'e9967a', darkseagreen: '8fbc8f', darkslateblue: '483d8b',
|
|
40
|
+
darkslategray: '2f4f4f', darkslategrey: '2f4f4f', darkturquoise: '00ced1', darkviolet: '9400d3',
|
|
41
|
+
deeppink: 'ff1493', deepskyblue: '00bfff', dimgray: '696969', dimgrey: '696969',
|
|
42
|
+
dodgerblue: '1e90ff', firebrick: 'b22222', floralwhite: 'fffaf0', forestgreen: '228b22',
|
|
43
|
+
fuchsia: 'ff00ff', gainsboro: 'dcdcdc', ghostwhite: 'f8f8ff', gold: 'ffd700',
|
|
44
|
+
goldenrod: 'daa520', gray: '808080', green: '008000', greenyellow: 'adff2f',
|
|
45
|
+
grey: '808080', honeydew: 'f0fff0', hotpink: 'ff69b4', indianred: 'cd5c5c',
|
|
46
|
+
indigo: '4b0082', ivory: 'fffff0', khaki: 'f0e68c', lavender: 'e6e6fa',
|
|
47
|
+
lavenderblush: 'fff0f5', lawngreen: '7cfc00', lemonchiffon: 'fffacd', lightblue: 'add8e6',
|
|
48
|
+
lightcoral: 'f08080', lightcyan: 'e0ffff', lightgoldenrodyellow: 'fafad2', lightgray: 'd3d3d3',
|
|
49
|
+
lightgreen: '90ee90', lightgrey: 'd3d3d3', lightpink: 'ffb6c1', lightsalmon: 'ffa07a',
|
|
50
|
+
lightseagreen: '20b2aa', lightskyblue: '87cefa', lightslategray: '778899', lightslategrey: '778899',
|
|
51
|
+
lightsteelblue: 'b0c4de', lightyellow: 'ffffe0', lime: '00ff00', limegreen: '32cd32',
|
|
52
|
+
linen: 'faf0e6', magenta: 'ff00ff', maroon: '800000', mediumaquamarine: '66cdaa',
|
|
53
|
+
mediumblue: '0000cd', mediumorchid: 'ba55d3', mediumpurple: '9370db', mediumseagreen: '3cb371',
|
|
54
|
+
mediumslateblue: '7b68ee', mediumspringgreen: '00fa9a', mediumturquoise: '48d1cc', mediumvioletred: 'c71585',
|
|
55
|
+
midnightblue: '191970', mintcream: 'f5fffa', mistyrose: 'ffe4e1', moccasin: 'ffe4b5',
|
|
56
|
+
navajowhite: 'ffdead', navy: '000080', oldlace: 'fdf5e6', olive: '808000',
|
|
57
|
+
olivedrab: '6b8e23', orange: 'ffa500', orangered: 'ff4500', orchid: 'da70d6',
|
|
58
|
+
palegoldenrod: 'eee8aa', palegreen: '98fb98', paleturquoise: 'afeeee', palevioletred: 'db7093',
|
|
59
|
+
papayawhip: 'ffefd5', peachpuff: 'ffdab9', peru: 'cd853f', pink: 'ffc0cb',
|
|
60
|
+
plum: 'dda0dd', powderblue: 'b0e0e6', purple: '800080', rebeccapurple: '663399',
|
|
61
|
+
red: 'ff0000', rosybrown: 'bc8f8f', royalblue: '4169e1', saddlebrown: '8b4513',
|
|
62
|
+
salmon: 'fa8072', sandybrown: 'f4a460', seagreen: '2e8b57', seashell: 'fff5ee',
|
|
63
|
+
sienna: 'a0522d', silver: 'c0c0c0', skyblue: '87ceeb', slateblue: '6a5acd',
|
|
64
|
+
slategray: '708090', slategrey: '708090', snow: 'fffafa', springgreen: '00ff7f',
|
|
65
|
+
steelblue: '4682b4', tan: 'd2b48c', teal: '008080', thistle: 'd8bfd8',
|
|
66
|
+
tomato: 'ff6347', turquoise: '40e0d0', violet: 'ee82ee', wheat: 'f5deb3',
|
|
67
|
+
white: 'ffffff', whitesmoke: 'f5f5f5', yellow: 'ffff00', yellowgreen: '9acd32',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const clamp255 = (n) => (n < 0 ? 0 : n > 255 ? 255 : n) | 0;
|
|
71
|
+
|
|
72
|
+
// Serialize {r,g,b,a}. Browsers emit `rgb(r, g, b)` when fully opaque, else
|
|
73
|
+
// `rgba(r, g, b, a)` with the alpha trimmed (0.5, not 0.50). Both sides of every
|
|
74
|
+
// comparison route through here, so the exact alpha rounding only has to be
|
|
75
|
+
// internally consistent.
|
|
76
|
+
function rgb(r, g, b, a) {
|
|
77
|
+
r = clamp255(r); g = clamp255(g); b = clamp255(b);
|
|
78
|
+
if (a === undefined || a >= 1) return `rgb(${r}, ${g}, ${b})`;
|
|
79
|
+
if (a < 0) a = 0;
|
|
80
|
+
const as = String(Math.round(a * 1000) / 1000);
|
|
81
|
+
return `rgba(${r}, ${g}, ${b}, ${as})`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hexToRgb(v) {
|
|
85
|
+
const h = v.slice(1);
|
|
86
|
+
const n = h.length;
|
|
87
|
+
if (!/^[0-9a-fA-F]+$/.test(h)) return null;
|
|
88
|
+
if (n === 3 || n === 4) {
|
|
89
|
+
const r = parseInt(h[0] + h[0], 16), g = parseInt(h[1] + h[1], 16), b = parseInt(h[2] + h[2], 16);
|
|
90
|
+
const a = n === 4 ? parseInt(h[3] + h[3], 16) / 255 : undefined;
|
|
91
|
+
return rgb(r, g, b, a);
|
|
92
|
+
}
|
|
93
|
+
if (n === 6 || n === 8) {
|
|
94
|
+
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
|
95
|
+
const a = n === 8 ? parseInt(h.slice(6, 8), 16) / 255 : undefined;
|
|
96
|
+
return rgb(r, g, b, a);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Re-serialize rgb()/rgba() to canonical spacing. Percentage components (rare in
|
|
102
|
+
// CSS-in-JS output) → null (passthrough) rather than guessing.
|
|
103
|
+
function normalizeRgb(v) {
|
|
104
|
+
const open = v.indexOf('(');
|
|
105
|
+
if (open === -1 || v[v.length - 1] !== ')') return null;
|
|
106
|
+
const parts = v.slice(open + 1, -1).split(/[,\s/]+/).filter(Boolean);
|
|
107
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
108
|
+
const nums = [];
|
|
109
|
+
for (let i = 0; i < parts.length; i++) {
|
|
110
|
+
if (parts[i].indexOf('%') !== -1) return null;
|
|
111
|
+
const f = parseFloat(parts[i]);
|
|
112
|
+
if (Number.isNaN(f)) return null;
|
|
113
|
+
nums.push(f);
|
|
114
|
+
}
|
|
115
|
+
return rgb(nums[0], nums[1], nums[2], nums[3]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hslToRgb(v) {
|
|
119
|
+
const open = v.indexOf('(');
|
|
120
|
+
if (open === -1 || v[v.length - 1] !== ')') return null;
|
|
121
|
+
const parts = v.slice(open + 1, -1).split(/[,\s/]+/).filter(Boolean);
|
|
122
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
123
|
+
let h = parseFloat(parts[0]);
|
|
124
|
+
const s = parseFloat(parts[1]) / 100;
|
|
125
|
+
const l = parseFloat(parts[2]) / 100;
|
|
126
|
+
if (Number.isNaN(h) || Number.isNaN(s) || Number.isNaN(l)) return null;
|
|
127
|
+
const a = parts[3] !== undefined ? parseFloat(parts[3]) : undefined;
|
|
128
|
+
h = ((h % 360) + 360) % 360 / 360;
|
|
129
|
+
const hue = (p, q, t) => {
|
|
130
|
+
if (t < 0) t += 1; if (t > 1) t -= 1;
|
|
131
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
132
|
+
if (t < 1 / 2) return q;
|
|
133
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
134
|
+
return p;
|
|
135
|
+
};
|
|
136
|
+
let r, g, b;
|
|
137
|
+
if (s === 0) { r = g = b = l; }
|
|
138
|
+
else {
|
|
139
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
140
|
+
const p = 2 * l - q;
|
|
141
|
+
r = hue(p, q, h + 1 / 3); g = hue(p, q, h); b = hue(p, q, h - 1 / 3);
|
|
142
|
+
}
|
|
143
|
+
return rgb(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Canonicalize one color value, or null if unrecognized (→ caller passthrough).
|
|
147
|
+
export function canonicalizeColor(value, includeNamed) {
|
|
148
|
+
if (!value) return null;
|
|
149
|
+
const v = value.trim();
|
|
150
|
+
if (v[0] === '#') return hexToRgb(v);
|
|
151
|
+
const lower = v.toLowerCase();
|
|
152
|
+
if (lower.startsWith('rgb')) return normalizeRgb(v);
|
|
153
|
+
if (lower.startsWith('hsl')) return hslToRgb(v);
|
|
154
|
+
if (includeNamed) {
|
|
155
|
+
if (lower === 'transparent') return 'rgba(0, 0, 0, 0)';
|
|
156
|
+
const hex = NAMED[lower];
|
|
157
|
+
if (hex) return hexToRgb('#' + hex);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { liveNodeList, liveHTMLCollection } from './collections.mjs';
|
|
|
15
15
|
import { matchesSelector, querySelector as qsel, querySelectorAll as qselAll } from './selectors.mjs';
|
|
16
16
|
import { serializeInner, serializeOuter } from './html-serialize.mjs';
|
|
17
17
|
import { CSSStyleSheet, styleSheetList } from './cssom.mjs';
|
|
18
|
+
import { COLOR_PROPS, canonicalizeColor } from './color.mjs';
|
|
18
19
|
import { SVGAnimatedString, SVGAnimatedLength, SVGAnimatedRect, SVG_LENGTH_ATTRS } from './svg.mjs';
|
|
19
20
|
|
|
20
21
|
// Per-node query-result cache keyed by (selector, document version). querySelectorAll
|
|
@@ -240,6 +241,20 @@ export class Node extends EventTarget {
|
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
get textContent() {
|
|
244
|
+
// <style> whose CSSOM sheet received insertRule() (emotion/styled-components
|
|
245
|
+
// "speedy" mode injects rules WITHOUT touching the node's text): reflect those
|
|
246
|
+
// rules in the serialized text, as browsers/jsdom do, so tests that scrape
|
|
247
|
+
// `querySelectorAll('style').map(s => s.textContent)` see the injected CSS.
|
|
248
|
+
// __sheet is only ever set on <style> elements (sheet getter gates on it), so
|
|
249
|
+
// this is one predicted-false read for every other element on the hot path.
|
|
250
|
+
const sheet = this.__sheet;
|
|
251
|
+
if (sheet && sheet.cssRules.length) {
|
|
252
|
+
const kids = this.__children();
|
|
253
|
+
let css = '';
|
|
254
|
+
for (let i = 0; i < kids.length; i++) if (kids[i].nodeType === TEXT_NODE) css += kids[i].data;
|
|
255
|
+
for (let i = 0; i < sheet.cssRules.length; i++) css += (css ? '\n' : '') + sheet.cssRules[i].cssText;
|
|
256
|
+
return css;
|
|
257
|
+
}
|
|
243
258
|
const kids = this.__children();
|
|
244
259
|
// hot leaf case (RTL getByText reads textContent on every element): a single
|
|
245
260
|
// text child → return its data directly, skipping the iterator + concat.
|
|
@@ -1341,11 +1356,19 @@ const SHORTHAND_OF = {
|
|
|
1341
1356
|
'padding-top': 'padding', 'padding-right': 'padding', 'padding-bottom': 'padding', 'padding-left': 'padding',
|
|
1342
1357
|
};
|
|
1343
1358
|
function styleGet(values, prop) {
|
|
1344
|
-
|
|
1345
|
-
if (
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1359
|
+
let v = values.get(prop);
|
|
1360
|
+
if (v === undefined) {
|
|
1361
|
+
const sh = SHORTHAND_OF[prop];
|
|
1362
|
+
if (sh) { const s = values.get(sh); if (s !== undefined && !/\s/.test(s.trim())) v = s; }
|
|
1363
|
+
}
|
|
1364
|
+
if (v === undefined) return '';
|
|
1365
|
+
// Inline CSSOM canonicalizes <color> values the way a browser does on read-back:
|
|
1366
|
+
// hex/rgb()/hsl() → rgb()/rgba(), but NAMED keywords stay as authored (includeNamed
|
|
1367
|
+
// =false) — `el.style.color='red'` reads back `'red'`, `'#fff'` reads back rgb().
|
|
1368
|
+
// This keeps el.style read-back consistent with getComputedStyle so jest-dom's
|
|
1369
|
+
// toHaveStyle (which normalizes the expected value via el.style) compares equal.
|
|
1370
|
+
if (COLOR_PROPS.has(prop)) { const c = canonicalizeColor(v, false); if (c) return c; }
|
|
1371
|
+
return v;
|
|
1349
1372
|
}
|
|
1350
1373
|
|
|
1351
1374
|
// WHATWG value-sanitization per input type. Returns the accepted value, or null
|
package/src/runtime/window.mjs
CHANGED
|
@@ -201,10 +201,49 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
import
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
// No static `node:` import — the runtime must load in a bare V8 isolate (no Node,
|
|
205
|
+
// no web-platform globals). Capture the HOST `performance` at module load, BEFORE
|
|
206
|
+
// turbo-dom installs its own `performance` global (else performanceNow → installed
|
|
207
|
+
// performance.now → performanceNow → ∞). Falls back to Date.now() in a bare V8.
|
|
208
|
+
const hostPerformance = globalThis.performance;
|
|
209
|
+
const performanceNow = typeof hostPerformance?.now === 'function'
|
|
210
|
+
? () => hostPerformance.now()
|
|
211
|
+
: () => Date.now();
|
|
212
|
+
|
|
213
|
+
// base64 without depending on the Node `Buffer` global — prefer the platform
|
|
214
|
+
// btoa/atob, then Buffer (Node), then a pure-JS fallback so a bare-isolate page
|
|
215
|
+
// that calls btoa/atob doesn't throw.
|
|
216
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
217
|
+
const turboBtoa = typeof globalThis.btoa === 'function'
|
|
218
|
+
? (s) => globalThis.btoa(s)
|
|
219
|
+
: typeof Buffer !== 'undefined'
|
|
220
|
+
? (s) => Buffer.from(String(s), 'binary').toString('base64')
|
|
221
|
+
: (s) => {
|
|
222
|
+
const str = String(s); let out = '';
|
|
223
|
+
for (let i = 0; i < str.length;) {
|
|
224
|
+
const c1 = str.charCodeAt(i++), c2 = str.charCodeAt(i++), c3 = str.charCodeAt(i++);
|
|
225
|
+
const e1 = c1 >> 2, e2 = ((c1 & 3) << 4) | (c2 >> 4);
|
|
226
|
+
let e3 = ((c2 & 15) << 2) | (c3 >> 6), e4 = c3 & 63;
|
|
227
|
+
if (isNaN(c2)) { e3 = e4 = 64; } else if (isNaN(c3)) { e4 = 64; }
|
|
228
|
+
out += B64[e1] + B64[e2] + (e3 === 64 ? '=' : B64[e3]) + (e4 === 64 ? '=' : B64[e4]);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
};
|
|
232
|
+
const turboAtob = typeof globalThis.atob === 'function'
|
|
233
|
+
? (s) => globalThis.atob(s)
|
|
234
|
+
: typeof Buffer !== 'undefined'
|
|
235
|
+
? (s) => Buffer.from(String(s), 'base64').toString('binary')
|
|
236
|
+
: (s) => {
|
|
237
|
+
const str = String(s).replace(/[^A-Za-z0-9+/]/g, ''); let out = '';
|
|
238
|
+
for (let i = 0; i < str.length;) {
|
|
239
|
+
const e1 = B64.indexOf(str[i++]), e2 = B64.indexOf(str[i++]);
|
|
240
|
+
const e3 = B64.indexOf(str[i++]), e4 = B64.indexOf(str[i++]);
|
|
241
|
+
out += String.fromCharCode((e1 << 2) | (e2 >> 4));
|
|
242
|
+
if (e3 !== -1 && e3 !== 64) out += String.fromCharCode(((e2 & 15) << 4) | (e3 >> 2));
|
|
243
|
+
if (e4 !== -1 && e4 !== 64) out += String.fromCharCode(((e3 & 3) << 6) | e4);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
};
|
|
208
247
|
|
|
209
248
|
// Resolve the host base classes ONCE, with minimal fallbacks so turbo-dom loads &
|
|
210
249
|
// runs in a bare V8 lacking web-platform globals. Where the platform provides the
|
|
@@ -344,8 +383,8 @@ const STATIC_BASE = {
|
|
|
344
383
|
Headers: globalThis.Headers, Request: globalThis.Request, Response: globalThis.Response,
|
|
345
384
|
FormData: TurboFormData, ReadableStream: globalThis.ReadableStream,
|
|
346
385
|
crypto: globalThis.crypto, Crypto: globalThis.Crypto, SubtleCrypto: globalThis.SubtleCrypto,
|
|
347
|
-
btoa: (s) =>
|
|
348
|
-
atob: (s) =>
|
|
386
|
+
btoa: (s) => turboBtoa(String(s)),
|
|
387
|
+
atob: (s) => turboAtob(String(s)),
|
|
349
388
|
MessageChannel: globalThis.MessageChannel, MessagePort: globalThis.MessagePort,
|
|
350
389
|
BroadcastChannel: globalThis.BroadcastChannel, EventSource: globalThis.EventSource,
|
|
351
390
|
reportError: (e) => { void e; },
|
|
Binary file
|