@jxsuite/import 0.34.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.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * In-browser style capture — runs inside page.evaluate().
3
+ *
4
+ * Walks the live DOM depth-first and records getComputedStyle for each element, keyed by a stable
5
+ * tree path (array of child-element indices) that matches the htmlToJx walk order. Also collects
6
+ * UA-default baselines per unique tagName by rendering throwaway elements.
7
+ */
8
+
9
+ import type { Page } from "puppeteer-core";
10
+
11
+ export interface CapturedStyle {
12
+ /** Depth-first element-index path from <body>, e.g. [0, 2, 1]. */
13
+ path: number[];
14
+ tagName: string;
15
+ styles: Record<string, string>;
16
+ }
17
+
18
+ export interface StyleCaptureResult {
19
+ /** Per-element captured styles (allowlisted props only). */
20
+ elements: CapturedStyle[];
21
+ /** UA-default baselines per tagName. */
22
+ uaDefaults: Record<string, Record<string, string>>;
23
+ /** Media queries discovered in the page's stylesheets. */
24
+ mediaQueries: string[];
25
+ /** CSS custom property declarations found in stylesheets (name → value). */
26
+ customProperties: Record<string, string>;
27
+ /** Computed styles from <html> and <body>, diffed against UA defaults. */
28
+ documentStyles: Record<string, string>;
29
+ }
30
+
31
+ /** Visually meaningful CSS properties worth capturing. */
32
+ const STYLE_ALLOWLIST: string[] = [
33
+ // Layout
34
+ "display",
35
+ "position",
36
+ "top",
37
+ "right",
38
+ "bottom",
39
+ "left",
40
+ "float",
41
+ "clear",
42
+ "z-index",
43
+ "overflow",
44
+ "overflow-x",
45
+ "overflow-y",
46
+ "box-sizing",
47
+ "vertical-align",
48
+ // Flex
49
+ "flex-direction",
50
+ "flex-wrap",
51
+ "justify-content",
52
+ "align-items",
53
+ "align-content",
54
+ "align-self",
55
+ "flex",
56
+ "flex-grow",
57
+ "flex-shrink",
58
+ "flex-basis",
59
+ "gap",
60
+ "row-gap",
61
+ "column-gap",
62
+ "order",
63
+ // Grid
64
+ "grid-template-columns",
65
+ "grid-template-rows",
66
+ "grid-column",
67
+ "grid-row",
68
+ "grid-area",
69
+ "grid-auto-flow",
70
+ "grid-auto-columns",
71
+ "grid-auto-rows",
72
+ // Box model
73
+ "width",
74
+ "height",
75
+ "min-width",
76
+ "min-height",
77
+ "max-width",
78
+ "max-height",
79
+ "margin-top",
80
+ "margin-right",
81
+ "margin-bottom",
82
+ "margin-left",
83
+ "padding-top",
84
+ "padding-right",
85
+ "padding-bottom",
86
+ "padding-left",
87
+ // Border
88
+ "border-top-width",
89
+ "border-right-width",
90
+ "border-bottom-width",
91
+ "border-left-width",
92
+ "border-top-style",
93
+ "border-right-style",
94
+ "border-bottom-style",
95
+ "border-left-style",
96
+ "border-top-color",
97
+ "border-right-color",
98
+ "border-bottom-color",
99
+ "border-left-color",
100
+ "border-top-left-radius",
101
+ "border-top-right-radius",
102
+ "border-bottom-right-radius",
103
+ "border-bottom-left-radius",
104
+ // Background
105
+ "background-color",
106
+ "background-image",
107
+ "background-size",
108
+ "background-position",
109
+ "background-repeat",
110
+ "background-clip",
111
+ // Typography
112
+ "font-family",
113
+ "font-size",
114
+ "font-weight",
115
+ "font-style",
116
+ "font-variant",
117
+ "line-height",
118
+ "letter-spacing",
119
+ "word-spacing",
120
+ "text-align",
121
+ "text-decoration",
122
+ "text-decoration-line",
123
+ "text-decoration-color",
124
+ "text-decoration-style",
125
+ "text-transform",
126
+ "text-indent",
127
+ "text-overflow",
128
+ "text-shadow",
129
+ "white-space",
130
+ "word-break",
131
+ "overflow-wrap",
132
+ "color",
133
+ // Visual
134
+ "opacity",
135
+ "visibility",
136
+ "box-shadow",
137
+ "transform",
138
+ "transition",
139
+ "cursor",
140
+ "outline-style",
141
+ "outline-width",
142
+ "outline-color",
143
+ "outline-offset",
144
+ // List
145
+ "list-style-type",
146
+ "list-style-position",
147
+ // Table
148
+ "table-layout",
149
+ "border-collapse",
150
+ "border-spacing",
151
+ // Content
152
+ "content",
153
+ "object-fit",
154
+ "object-position",
155
+ // Appearance
156
+ "appearance",
157
+ "pointer-events",
158
+ "user-select",
159
+ "resize",
160
+ ];
161
+
162
+ /**
163
+ * Run the style capture in a puppeteer page. Executes entirely in-browser via page.evaluate() — no
164
+ * round-trips per element.
165
+ */
166
+ export async function captureStyles(page: Page): Promise<StyleCaptureResult> {
167
+ return page.evaluate((allowlist: string[]) => {
168
+ const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
169
+ const tagsSeen = new Set<string>();
170
+
171
+ // Depth-first walk matching htmlToJx traversal order (element children only,
172
+ // Skipping text/comment nodes — same as hast element indexing).
173
+ function walk(el: Element, path: number[]) {
174
+ const tag = el.tagName.toLowerCase();
175
+ tagsSeen.add(tag);
176
+
177
+ const cs = window.getComputedStyle(el);
178
+ const styles: Record<string, string> = {};
179
+ for (const prop of allowlist) {
180
+ const val = cs.getPropertyValue(prop);
181
+ if (val) {
182
+ styles[prop] = val;
183
+ }
184
+ }
185
+ elements.push({ path: [...path], tagName: tag, styles });
186
+
187
+ let childIdx = 0;
188
+ for (const child of el.children) {
189
+ walk(child, [...path, childIdx]);
190
+ childIdx += 1;
191
+ }
192
+ }
193
+
194
+ // Walk from <body>'s children (htmlToJx receives body.innerHTML, not body itself)
195
+ let bodyChildIdx = 0;
196
+ for (const child of document.body.children) {
197
+ walk(child, [bodyChildIdx]);
198
+ bodyChildIdx += 1;
199
+ }
200
+
201
+ // Build UA-default baselines per unique tagName
202
+ const uaDefaults: Record<string, Record<string, string>> = {};
203
+ const sandbox = document.createElement("div");
204
+ sandbox.style.cssText =
205
+ "position:fixed;top:-9999px;left:-9999px;visibility:hidden;pointer-events:none;";
206
+ document.body.append(sandbox);
207
+
208
+ for (const tag of tagsSeen) {
209
+ try {
210
+ const probe = document.createElement(tag);
211
+ sandbox.append(probe);
212
+ const cs = window.getComputedStyle(probe);
213
+ const defaults: Record<string, string> = {};
214
+ for (const prop of allowlist) {
215
+ const val = cs.getPropertyValue(prop);
216
+ if (val) {
217
+ defaults[prop] = val;
218
+ }
219
+ }
220
+ uaDefaults[tag] = defaults;
221
+ probe.remove();
222
+ } catch {
223
+ // Skip exotic elements
224
+ }
225
+ }
226
+ sandbox.remove();
227
+
228
+ // Discover @media queries from stylesheets (recurse into @layer, @supports, etc.)
229
+ const mediaQueries: string[] = [];
230
+ const mediaSeen = new Set<string>();
231
+ function collectMediaRules(rules: CSSRuleList) {
232
+ for (const rule of rules) {
233
+ if (rule instanceof CSSMediaRule) {
234
+ const q = rule.conditionText;
235
+ if (!mediaSeen.has(q)) {
236
+ mediaSeen.add(q);
237
+ mediaQueries.push(q);
238
+ }
239
+ collectMediaRules(rule.cssRules);
240
+ } else if ("cssRules" in rule) {
241
+ collectMediaRules((rule as CSSGroupingRule).cssRules);
242
+ }
243
+ }
244
+ }
245
+ for (const sheet of document.styleSheets) {
246
+ try {
247
+ collectMediaRules(sheet.cssRules);
248
+ } catch {
249
+ // Cross-origin stylesheet — cssRules inaccessible
250
+ }
251
+ }
252
+
253
+ // Extract CSS custom property declarations (R5 — design tokens)
254
+ const customProperties: Record<string, string> = {};
255
+ function collectCustomProps(rules: CSSRuleList) {
256
+ for (const rule of rules) {
257
+ if (rule instanceof CSSStyleRule) {
258
+ const { style } = rule;
259
+ for (const name of style) {
260
+ if (name.startsWith("--")) {
261
+ const val = style.getPropertyValue(name).trim();
262
+ if (val && !val.includes("var(")) {
263
+ customProperties[name] = val;
264
+ }
265
+ }
266
+ }
267
+ }
268
+ if ("cssRules" in rule) {
269
+ try {
270
+ collectCustomProps((rule as CSSGroupingRule).cssRules);
271
+ } catch {
272
+ /* Skip */
273
+ }
274
+ }
275
+ }
276
+ }
277
+ for (const sheet of document.styleSheets) {
278
+ try {
279
+ collectCustomProps(sheet.cssRules);
280
+ } catch {
281
+ /* Cross-origin */
282
+ }
283
+ }
284
+
285
+ // Capture <html> and <body> styles that won't be in the element tree
286
+ // (the Jx tree starts from body.children, not body itself)
287
+ const documentStyles: Record<string, string> = {};
288
+
289
+ const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", ""]);
290
+ function isDefault(val: string, probeVal: string): boolean {
291
+ return !val || val === probeVal || TRANSPARENT.has(val);
292
+ }
293
+
294
+ const htmlCs = window.getComputedStyle(document.documentElement);
295
+ const bodyCs = window.getComputedStyle(document.body);
296
+
297
+ // Create a clean probe in an iframe to get true UA defaults
298
+ // (a probe appended to body would inherit body's own styles)
299
+ const iframe = document.createElement("iframe");
300
+ iframe.style.cssText = "position:fixed;top:-9999px;left:-9999px;width:0;height:0;border:none;";
301
+ document.body.append(iframe);
302
+ const iframeDoc = iframe.contentDocument!;
303
+ const probeDiv = iframeDoc.createElement("div");
304
+ iframeDoc.body.append(probeDiv);
305
+ const probeCs = iframe.contentWindow!.getComputedStyle(probeDiv);
306
+ const probeBodyCs = iframe.contentWindow!.getComputedStyle(iframeDoc.body);
307
+
308
+ const DOC_PROPS = [
309
+ "background-color",
310
+ "background-image",
311
+ "color",
312
+ "font-family",
313
+ "font-size",
314
+ "line-height",
315
+ "letter-spacing",
316
+ ];
317
+ for (const prop of DOC_PROPS) {
318
+ const bodyVal = bodyCs.getPropertyValue(prop);
319
+ const htmlVal = htmlCs.getPropertyValue(prop);
320
+ const probeDefault = probeCs.getPropertyValue(prop);
321
+ const probeBodyDefault = probeBodyCs.getPropertyValue(prop);
322
+
323
+ // Body takes priority, but skip transparent/default values
324
+ if (!isDefault(bodyVal, probeBodyDefault)) {
325
+ documentStyles[prop] = bodyVal;
326
+ } else if (!isDefault(htmlVal, probeDefault)) {
327
+ documentStyles[prop] = htmlVal;
328
+ }
329
+ }
330
+ iframe.remove();
331
+
332
+ return { elements, uaDefaults, mediaQueries, customProperties, documentStyles };
333
+ }, STYLE_ALLOWLIST);
334
+ }
335
+
336
+ /**
337
+ * Re-capture computed styles at a different viewport width (for $media extraction). Returns only
338
+ * the per-element styles — no UA defaults or media queries (reuse from base).
339
+ */
340
+ export async function captureStylesAtWidth(page: Page, width: number): Promise<CapturedStyle[]> {
341
+ await page.setViewport({ width, height: 900 });
342
+ // Let layout reflow settle
343
+ await page.evaluate(
344
+ () =>
345
+ new Promise<void>((r) => {
346
+ requestAnimationFrame(() => {
347
+ requestAnimationFrame(() => {
348
+ r();
349
+ });
350
+ });
351
+ }),
352
+ );
353
+
354
+ return page.evaluate((allowlist: string[]) => {
355
+ const elements: { path: number[]; tagName: string; styles: Record<string, string> }[] = [];
356
+
357
+ function walk(el: Element, path: number[]) {
358
+ const tag = el.tagName.toLowerCase();
359
+ const cs = window.getComputedStyle(el);
360
+ const styles: Record<string, string> = {};
361
+ for (const prop of allowlist) {
362
+ const val = cs.getPropertyValue(prop);
363
+ if (val) {
364
+ styles[prop] = val;
365
+ }
366
+ }
367
+ elements.push({ path: [...path], tagName: tag, styles });
368
+
369
+ let childIdx = 0;
370
+ for (const child of el.children) {
371
+ walk(child, [...path, childIdx]);
372
+ childIdx += 1;
373
+ }
374
+ }
375
+
376
+ let bodyChildIdx = 0;
377
+ for (const child of document.body.children) {
378
+ walk(child, [bodyChildIdx]);
379
+ bodyChildIdx += 1;
380
+ }
381
+
382
+ return elements;
383
+ }, STYLE_ALLOWLIST);
384
+ }
385
+
386
+ export { STYLE_ALLOWLIST };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Diff captured computed styles against UA-default baselines. Produces a minimal set of meaningful
3
+ * style declarations per element.
4
+ */
5
+
6
+ import type { CapturedStyle } from "./style-capture.ts";
7
+
8
+ export interface DiffedStyle {
9
+ path: number[];
10
+ style: Record<string, string | number>;
11
+ }
12
+
13
+ /** Convert a kebab-case CSS property to camelCase for Jx style objects. */
14
+ export function kebabToCamel(prop: string): string {
15
+ if (prop.startsWith("-webkit-") || prop.startsWith("-moz-") || prop.startsWith("-ms-")) {
16
+ const withoutPrefix = prop.replace(/^-(?:webkit|moz|ms)-/, "");
17
+ const camel = withoutPrefix.replaceAll(/-([a-z])/g, (_, c: string) => c.toUpperCase());
18
+ const vendor = prop.startsWith("-webkit-") ? "webkit" : prop.startsWith("-moz-") ? "moz" : "ms";
19
+ return `${vendor}${camel[0]!.toUpperCase()}${camel.slice(1)}`;
20
+ }
21
+ return prop.replaceAll(/-([a-z])/g, (_, c: string) => c.toUpperCase());
22
+ }
23
+
24
+ /**
25
+ * Values that are effectively "no value set" — drop them even if they differ from UA default
26
+ * (they're computed noise, not intentional styling).
27
+ */
28
+ const NOISE_VALUES = new Set([
29
+ "auto",
30
+ "normal",
31
+ "none",
32
+ "0px",
33
+ "0s",
34
+ "start",
35
+ "baseline",
36
+ "stretch",
37
+ "visible",
38
+ "content-box",
39
+ "currentcolor",
40
+ "medium none currentcolor",
41
+ ]);
42
+
43
+ /**
44
+ * Properties where "auto" / "normal" / "none" IS meaningful and should not be dropped — e.g.,
45
+ * overflow: hidden vs overflow: visible.
46
+ */
47
+ const KEEP_NOISE_FOR = new Set([
48
+ "overflow",
49
+ "overflow-x",
50
+ "overflow-y",
51
+ "display",
52
+ "visibility",
53
+ "position",
54
+ "pointer-events",
55
+ "cursor",
56
+ "white-space",
57
+ "text-overflow",
58
+ "object-fit",
59
+ "flex-wrap",
60
+ "flex-direction",
61
+ "box-sizing",
62
+ "text-align",
63
+ "text-decoration",
64
+ "text-decoration-line",
65
+ "text-transform",
66
+ "word-break",
67
+ "overflow-wrap",
68
+ "list-style-type",
69
+ "table-layout",
70
+ "border-collapse",
71
+ "appearance",
72
+ "resize",
73
+ "user-select",
74
+ ]);
75
+
76
+ /**
77
+ * Try to convert a CSS value to a number where appropriate (e.g., "0" → 0, opacity "0.5" → 0.5).
78
+ * Keeps strings for anything with units or complex values.
79
+ */
80
+ function maybeNumeric(value: string): string | number {
81
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
82
+ return Number(value);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ /**
88
+ * Diff a single element's captured styles against its tagName's UA defaults. Returns only the
89
+ * declarations that represent intentional styling.
90
+ */
91
+ export function diffStyles(
92
+ captured: Record<string, string>,
93
+ uaDefaults: Record<string, string>,
94
+ ): Record<string, string | number> {
95
+ const result: Record<string, string | number> = {};
96
+
97
+ for (const [prop, value] of Object.entries(captured)) {
98
+ const uaValue = uaDefaults[prop] ?? "";
99
+
100
+ // Same as default → skip
101
+ if (value === uaValue) {
102
+ continue;
103
+ }
104
+
105
+ // Drop noise values unless the property's noise values are meaningful
106
+ if (!KEEP_NOISE_FOR.has(prop) && NOISE_VALUES.has(value)) {
107
+ continue;
108
+ }
109
+
110
+ // Even for KEEP_NOISE_FOR properties, skip if value equals UA default
111
+ // (already handled above) — but don't skip if it's a meaningful override
112
+
113
+ const camelProp = kebabToCamel(prop);
114
+ result[camelProp] = maybeNumeric(value);
115
+ }
116
+
117
+ return result;
118
+ }
119
+
120
+ /**
121
+ * Diff all captured elements against UA defaults. Returns an array of { path, style } for elements
122
+ * with non-empty diffs.
123
+ */
124
+ export function diffAllStyles(
125
+ elements: CapturedStyle[],
126
+ uaDefaults: Record<string, Record<string, string>>,
127
+ ): DiffedStyle[] {
128
+ const results: DiffedStyle[] = [];
129
+
130
+ for (const el of elements) {
131
+ const defaults = uaDefaults[el.tagName] ?? {};
132
+ const style = diffStyles(el.styles, defaults);
133
+
134
+ if (Object.keys(style).length > 0) {
135
+ results.push({ path: el.path, style });
136
+ }
137
+ }
138
+
139
+ return results;
140
+ }
141
+
142
+ /**
143
+ * Compute style deltas between a base capture and a breakpoint capture. Returns only properties
144
+ * that changed at the new viewport width.
145
+ */
146
+ export function computeMediaDelta(
147
+ baseElements: CapturedStyle[],
148
+ bpElements: CapturedStyle[],
149
+ uaDefaults: Record<string, Record<string, string>>,
150
+ ): DiffedStyle[] {
151
+ const baseByPath = new Map<string, CapturedStyle>();
152
+ for (const el of baseElements) {
153
+ baseByPath.set(el.path.join(","), el);
154
+ }
155
+
156
+ const deltas: DiffedStyle[] = [];
157
+
158
+ for (const bpEl of bpElements) {
159
+ const key = bpEl.path.join(",");
160
+ const baseEl = baseByPath.get(key);
161
+ if (!baseEl) {
162
+ continue;
163
+ }
164
+
165
+ const defaults = uaDefaults[bpEl.tagName] ?? {};
166
+ const baseStyle = diffStyles(baseEl.styles, defaults);
167
+ const bpStyle = diffStyles(bpEl.styles, defaults);
168
+
169
+ // Only keep properties that changed from base
170
+ const delta: Record<string, string | number> = {};
171
+ for (const [prop, val] of Object.entries(bpStyle)) {
172
+ if (baseStyle[prop] !== val) {
173
+ delta[prop] = val;
174
+ }
175
+ }
176
+
177
+ if (Object.keys(delta).length > 0) {
178
+ deltas.push({ path: bpEl.path, style: delta });
179
+ }
180
+ }
181
+
182
+ return deltas;
183
+ }
package/src/to-jx.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { htmlToJx } from "@jxsuite/parser/html-to-jx";
2
+ import type { JxElement } from "@jxsuite/schema/types";
3
+
4
+ export interface ToJxResult {
5
+ /** The page document root — a wrapper div with the converted children. */
6
+ document: JxElement;
7
+ /** Element count in the emitted tree. */
8
+ nodeCount: number;
9
+ /** Inline <style> contents collected for Phase 1 CSS resolution. */
10
+ collectedStyles: string[];
11
+ }
12
+
13
+ const STRIP_TAGS = new Set(["script", "noscript", "iframe", "object", "embed", "link", "meta"]);
14
+
15
+ function countNodes(node: JxElement | string): number {
16
+ if (typeof node === "string") {
17
+ return 0;
18
+ }
19
+ let n = 1;
20
+ if (Array.isArray(node.children)) {
21
+ for (const c of node.children) {
22
+ n += countNodes(c as JxElement | string);
23
+ }
24
+ }
25
+ return n;
26
+ }
27
+
28
+ function stripTags(nodes: (JxElement | string)[]): {
29
+ cleaned: (JxElement | string)[];
30
+ styles: string[];
31
+ } {
32
+ const cleaned: (JxElement | string)[] = [];
33
+ const styles: string[] = [];
34
+
35
+ for (const node of nodes) {
36
+ if (typeof node === "string") {
37
+ cleaned.push(node);
38
+ continue;
39
+ }
40
+
41
+ const tag = String(node.tagName ?? "").toLowerCase();
42
+
43
+ // Collect inline <style> content for Phase 1
44
+ if (tag === "style") {
45
+ if (typeof node.textContent === "string") {
46
+ styles.push(node.textContent);
47
+ }
48
+ if (Array.isArray(node.children)) {
49
+ for (const c of node.children) {
50
+ if (typeof c === "string") {
51
+ styles.push(c);
52
+ }
53
+ }
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (STRIP_TAGS.has(tag)) {
59
+ continue;
60
+ }
61
+
62
+ // Recurse into children
63
+ if (Array.isArray(node.children)) {
64
+ const sub = stripTags(node.children as (JxElement | string)[]);
65
+ node.children = sub.cleaned as JxElement[];
66
+ styles.push(...sub.styles);
67
+ }
68
+
69
+ cleaned.push(node);
70
+ }
71
+
72
+ return { cleaned, styles };
73
+ }
74
+
75
+ export function convertToJx(bodyHtml: string): ToJxResult {
76
+ const rawNodes = htmlToJx(bodyHtml);
77
+ const { cleaned, styles } = stripTags(rawNodes);
78
+
79
+ const document: JxElement = {
80
+ tagName: "div",
81
+ children: cleaned as JxElement[],
82
+ };
83
+
84
+ return {
85
+ document,
86
+ nodeCount: countNodes(document),
87
+ collectedStyles: styles,
88
+ };
89
+ }