@jxsuite/import 0.40.9 → 0.40.10

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.
@@ -1,5 +1,5 @@
1
1
  import type { JxElement } from "@jxsuite/schema/types";
2
- import { displayTagName } from "@jxsuite/schema/guards";
2
+ import { displayTagName, isBoolean, isNumber, isString } from "@jxsuite/schema/guards";
3
3
 
4
4
  export interface ComponentizeOptions {
5
5
  minInstances?: number;
@@ -25,27 +25,27 @@ interface SubtreeLocation {
25
25
  }
26
26
 
27
27
  function nodeDepth(node: JxElement | string): number {
28
- if (typeof node === "string") {
28
+ if (isString(node)) {
29
29
  return 0;
30
30
  }
31
31
  let max = 0;
32
32
  if (Array.isArray(node.children)) {
33
33
  for (const c of node.children) {
34
- max = Math.max(max, nodeDepth(c as JxElement | string));
34
+ max = Math.max(max, nodeDepth(c));
35
35
  }
36
36
  }
37
37
  return 1 + max;
38
38
  }
39
39
 
40
40
  function structuralHash(node: JxElement | string): string {
41
- if (typeof node === "string") {
41
+ if (isString(node)) {
42
42
  return "#text";
43
43
  }
44
44
  const tag = node.tagName ?? "div";
45
45
  const childHashes: string[] = [];
46
46
  if (Array.isArray(node.children)) {
47
47
  for (const child of node.children) {
48
- childHashes.push(structuralHash(child as JxElement | string));
48
+ childHashes.push(structuralHash(child));
49
49
  }
50
50
  }
51
51
  if (node.textContent) {
@@ -65,11 +65,13 @@ function structuralHash(node: JxElement | string): string {
65
65
  const PRESENTATION_ATTRS = new Set(["class", "className"]);
66
66
 
67
67
  function collectLeafValues(node: JxElement | string, prefix: string, out: Map<string, string>) {
68
- if (typeof node === "string") {
68
+ if (isString(node)) {
69
69
  out.set(`${prefix}.$text`, node);
70
70
  return;
71
71
  }
72
72
  if (node.textContent) {
73
+ /* SAFETY: import trees come from `htmlToJx`, which sets `textContent` only from a lone string
74
+ child, so a truthy `textContent` here is a string and never a `$ref` binding. */
73
75
  out.set(`${prefix}.textContent`, node.textContent as string);
74
76
  }
75
77
  if (node.attributes) {
@@ -77,14 +79,14 @@ function collectLeafValues(node: JxElement | string, prefix: string, out: Map<st
77
79
  if (PRESENTATION_ATTRS.has(k)) {
78
80
  continue;
79
81
  }
80
- if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
82
+ if (isString(v) || isNumber(v) || isBoolean(v)) {
81
83
  out.set(`${prefix}.attr.${k}`, String(v));
82
84
  }
83
85
  }
84
86
  }
85
87
  if (Array.isArray(node.children)) {
86
- for (let i = 0; i < node.children.length; i++) {
87
- collectLeafValues(node.children[i] as JxElement | string, `${prefix}.${i}`, out);
88
+ for (const [i, child] of node.children.entries()) {
89
+ collectLeafValues(child, `${prefix}.${i}`, out);
88
90
  }
89
91
  }
90
92
  }
@@ -111,19 +113,11 @@ function leafPathToStateName(leafPath: string): string {
111
113
  }
112
114
 
113
115
  function applyStateInterpolation(
114
- node: JxElement | string,
116
+ node: JxElement,
115
117
  prefix: string,
116
118
  varyingPaths: Set<string>,
117
119
  nameMap: Map<string, string>,
118
- ): JxElement | string {
119
- if (typeof node === "string") {
120
- const key = `${prefix}.$text`;
121
- if (varyingPaths.has(key)) {
122
- return `\${state.${nameMap.get(key)}}`;
123
- }
124
- return node;
125
- }
126
-
120
+ ): JxElement {
127
121
  const result: JxElement = { ...node };
128
122
 
129
123
  if (node.textContent) {
@@ -144,9 +138,14 @@ function applyStateInterpolation(
144
138
  }
145
139
 
146
140
  if (Array.isArray(node.children)) {
147
- result.children = node.children.map((child, i) =>
148
- applyStateInterpolation(child as JxElement | string, `${prefix}.${i}`, varyingPaths, nameMap),
149
- ) as JxElement[];
141
+ result.children = node.children.map((child, i) => {
142
+ const childPrefix = `${prefix}.${i}`;
143
+ if (!isString(child)) {
144
+ return applyStateInterpolation(child, childPrefix, varyingPaths, nameMap);
145
+ }
146
+ const key = `${childPrefix}.$text`;
147
+ return varyingPaths.has(key) ? `\${state.${nameMap.get(key)}}` : child;
148
+ });
150
149
  }
151
150
 
152
151
  return result;
@@ -170,15 +169,17 @@ function setNodeAt(root: JxElement, path: number[], replacement: JxElement): JxE
170
169
 
171
170
  const result: JxElement = { ...root };
172
171
  if (Array.isArray(root.children)) {
173
- result.children = [...root.children] as JxElement[];
172
+ const children = [...root.children];
173
+ result.children = children;
174
174
  const head = path[0]!;
175
175
  const rest = path.slice(1);
176
176
  if (rest.length === 0) {
177
- (result.children as JxElement[])[head] = replacement;
177
+ children[head] = replacement;
178
178
  } else {
179
- const child = result.children[head];
180
- if (typeof child !== "string") {
181
- (result.children as JxElement[])[head] = setNodeAt(child as JxElement, rest, replacement);
179
+ // Paths come from `collectSubtrees`, so `head` always indexes an existing child.
180
+ const child = children[head];
181
+ if (child !== undefined && !isString(child)) {
182
+ children[head] = setNodeAt(child, rest, replacement);
182
183
  }
183
184
  }
184
185
  }
@@ -231,10 +232,9 @@ function collectSubtrees(
231
232
  out.push({ pageRoute, path: [...path], node });
232
233
  }
233
234
  if (Array.isArray(node.children)) {
234
- for (let i = 0; i < node.children.length; i++) {
235
- const child = node.children[i];
236
- if (typeof child !== "string") {
237
- collectSubtrees(child as JxElement, pageRoute, [...path, i], minDepth, out);
235
+ for (const [i, child] of node.children.entries()) {
236
+ if (!isString(child)) {
237
+ collectSubtrees(child, pageRoute, [...path, i], minDepth, out);
238
238
  }
239
239
  }
240
240
  }
@@ -343,12 +343,7 @@ export function componentize(
343
343
  state[stateName] = allLeaves[0]!.get(leafPath) ?? "";
344
344
  }
345
345
 
346
- const template = applyStateInterpolation(
347
- unclaimed[0]!.node,
348
- "root",
349
- varyingPaths,
350
- nameMap,
351
- ) as JxElement;
346
+ const template = applyStateInterpolation(unclaimed[0]!.node, "root", varyingPaths, nameMap);
352
347
 
353
348
  const component: ExtractedComponent = {
354
349
  $id: pascalName,
package/src/crawl.ts CHANGED
@@ -47,7 +47,7 @@ export interface CrawlOptions {
47
47
  breakpoints?: BreakpointPolicy | undefined;
48
48
  onProgress?: (msg: string) => void;
49
49
  /** Abort the crawl between pages; the loop throws before capturing the next page. */
50
- signal?: AbortSignal;
50
+ signal?: AbortSignal | undefined;
51
51
  }
52
52
 
53
53
  export interface CrawledPage {
@@ -309,7 +309,7 @@ export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
309
309
  options.breakpoints ?? DEFAULT_BREAKPOINT_POLICY,
310
310
  ).keep;
311
311
  if (breakpointPlan.length < declared.length) {
312
- onProgress?.(
312
+ onProgress(
313
313
  ` ${declared.length} breakpoints declared, keeping ` +
314
314
  `${breakpointPlan.length}: ${breakpointPlan.map((b) => b.name).join(", ")}\n`,
315
315
  );
package/src/css-tokens.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * var(--name) references. Hoists token declarations for project.json.$style.
4
4
  */
5
5
 
6
+ import { isString } from "@jxsuite/schema/guards";
6
7
  import type { DiffedStyle } from "./style-diff.ts";
7
8
 
8
9
  export interface TokenExtractionResult {
@@ -125,7 +126,7 @@ export function applyTokens(
125
126
  if (!TOKEN_ELIGIBLE_PROPS.has(prop)) {
126
127
  continue;
127
128
  }
128
- if (typeof value !== "string") {
129
+ if (!isString(value)) {
129
130
  continue;
130
131
  }
131
132
  // Already a var() reference
@@ -25,7 +25,8 @@
25
25
  * imply, so their measurements are kept.
26
26
  */
27
27
 
28
- import type { JxElement } from "@jxsuite/schema/types";
28
+ import { isString } from "@jxsuite/schema/guards";
29
+ import type { JxElement, JxStyle } from "@jxsuite/schema/types";
29
30
 
30
31
  /** Elements sized by their own content or attributes rather than by their container. */
31
32
  const REPLACED = new Set([
@@ -49,8 +50,8 @@ const OUT_OF_FLOW = new Set(["absolute", "fixed", "sticky"]);
49
50
  /** A bare pixel length, which is the only shape a measurement takes. */
50
51
  const PIXEL_LENGTH = /^-?[\d.]+px$/;
51
52
 
52
- function isMeasured(value: unknown): boolean {
53
- return typeof value === "string" && PIXEL_LENGTH.test(value);
53
+ function isMeasured(value: JxStyle[string]): boolean {
54
+ return isString(value) && PIXEL_LENGTH.test(value);
54
55
  }
55
56
 
56
57
  /**
@@ -62,30 +63,28 @@ function isMeasured(value: unknown): boolean {
62
63
  * nothing else in the document that would reproduce it.
63
64
  */
64
65
  function hasContent(node: JxElement): boolean {
65
- if (typeof node.textContent === "string" && node.textContent.trim().length > 0) {
66
+ if (isString(node.textContent) && node.textContent.trim().length > 0) {
66
67
  return true;
67
68
  }
68
69
  const children = Array.isArray(node.children) ? node.children : [];
69
- return children.some((child) =>
70
- typeof child === "string" ? child.trim().length > 0 : child !== undefined,
71
- );
70
+ return children.some((child) => !isString(child) || child.trim().length > 0);
72
71
  }
73
72
 
74
73
  /**
75
74
  * Remove measured geometry from a style object, in place.
76
75
  *
77
76
  * @param {JxElement} node - The element the style belongs to; its tag and content decide the answer
78
- * @param {Record<string, unknown>} style - The style object to filter, mutated
79
- * @param {Record<string, unknown>} [context] - Where `position` and `display` are read from, when
80
- * they are not in `style` itself. A breakpoint delta carries only what CHANGED, so it usually
81
- * states neither, and judging it on its own would strip an absolutely-positioned box's width on
82
- * the grounds that it looked like an in-flow one.
77
+ * @param {JxStyle} style - The style object to filter, mutated
78
+ * @param {JxStyle} [context] - Where `position` and `display` are read from, when they are not in
79
+ * `style` itself. A breakpoint delta carries only what CHANGED, so it usually states neither, and
80
+ * judging it on its own would strip an absolutely-positioned box's width on the grounds that it
81
+ * looked like an in-flow one.
83
82
  * @returns {number} How many declarations were removed
84
83
  */
85
84
  export function dropDerivedGeometry(
86
85
  node: JxElement,
87
- style: Record<string, unknown>,
88
- context: Record<string, unknown> = style,
86
+ style: JxStyle,
87
+ context: JxStyle = style,
89
88
  ): number {
90
89
  const tag = String(node.tagName ?? "").toLowerCase();
91
90
  if (REPLACED.has(tag)) {
package/src/emit.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { JxElement } from "@jxsuite/schema/types";
1
+ import { isString } from "@jxsuite/schema/guards";
2
+ import type { JxElement, JxHeadEntry } from "@jxsuite/schema/types";
2
3
  import { componentize } from "./componentize.ts";
3
4
  import { stripClasses } from "./strip-classes.ts";
4
5
  import type { ComponentizeOptions, ComponentizeResult } from "./componentize.ts";
@@ -19,6 +20,19 @@ function mediaKeyWidth(key: string): number {
19
20
  return Number.isFinite(width) ? width : -1;
20
21
  }
21
22
 
23
+ /**
24
+ * The `project.json` an import writes: only keys the project schema declares, in the order they are
25
+ * written (`$media`, `style` and `$head` are added after the three seeded keys, when present).
26
+ */
27
+ interface ImportedProjectConfig {
28
+ name: string;
29
+ imports: Record<string, string>;
30
+ images: { optimize: boolean };
31
+ $media?: Record<string, string>;
32
+ style?: Record<string, string>;
33
+ $head?: JxHeadEntry[];
34
+ }
35
+
22
36
  export interface EmitResult {
23
37
  /** Project-relative, forward-slashed paths of every file written, in the order they were written. */
24
38
  files: string[];
@@ -123,7 +137,7 @@ export async function emitMultiPageProject({
123
137
  * nearest candidate and the wrong one: it is the site's own user-facing description, and
124
138
  * "Imported from …" is provenance, not a description of the site. The importer reports it instead.
125
139
  */
126
- const projectJson: Record<string, unknown> = {
140
+ const projectJson: ImportedProjectConfig = {
127
141
  name: title || "Imported Site",
128
142
  imports: {},
129
143
  images: { optimize: false },
@@ -139,7 +153,7 @@ export async function emitMultiPageProject({
139
153
  const sorted = Object.entries(breakpoints).toSorted(
140
154
  ([a], [b]) => mediaKeyWidth(a) - mediaKeyWidth(b),
141
155
  );
142
- projectJson.$media = { ...(projectJson.$media as object), ...Object.fromEntries(sorted) };
156
+ projectJson.$media = { ...projectJson.$media, ...Object.fromEntries(sorted) };
143
157
  }
144
158
 
145
159
  if (styleTokens && Object.keys(styleTokens).length > 0) {
@@ -172,10 +186,9 @@ export async function emitMultiPageProject({
172
186
  lost it on the way out, and every interpolation referencing it went dead. */
173
187
  const derived: Record<string, string> = {};
174
188
  extractStateDefaults(comp.template, derived);
175
- const declared = (comp.template.state ?? {}) as Record<string, unknown>;
176
- const state = { ...derived, ...declared };
189
+ const state = { ...derived, ...comp.template.state };
177
190
  if (Object.keys(state).length > 0) {
178
- compDoc.state = state as Record<string, string>;
191
+ compDoc.state = state;
179
192
  }
180
193
 
181
194
  componentFiles.set(fileName, compDoc);
@@ -231,7 +244,7 @@ export async function emitMultiPageProject({
231
244
  if (!projectJson.$head) {
232
245
  projectJson.$head = [];
233
246
  }
234
- (projectJson.$head as unknown[]).push({
247
+ projectJson.$head.push({
235
248
  tagName: "link",
236
249
  attributes: { rel: "stylesheet", href: "/assets/fonts.css" },
237
250
  });
@@ -267,19 +280,14 @@ export async function emitMultiPageProject({
267
280
  // Build $elements refs for component registration
268
281
  const elementRefs: JxElement[] =
269
282
  componentFiles.size > 0
270
- ? [...componentFiles.keys()].map(
271
- (f) => ({ $ref: `../components/${f}` }) as unknown as JxElement,
272
- )
283
+ ? [...componentFiles.keys()].map((f) => ({ $ref: `../components/${f}` }))
273
284
  : [];
274
285
 
275
286
  // Write each page
276
287
  for (const [route, doc] of finalPages) {
277
288
  const pageDoc = { ...doc };
278
289
  if (elementRefs.length > 0) {
279
- pageDoc.$elements = [
280
- ...elementRefs,
281
- ...((pageDoc.$elements as JxElement[]) ?? []),
282
- ] as JxElement[];
290
+ prependElementRefs(pageDoc, elementRefs);
283
291
  }
284
292
  await io.write(route, `${JSON.stringify(pageDoc, null, 2)}\n`);
285
293
  files.push(route);
@@ -292,10 +300,7 @@ export async function emitMultiPageProject({
292
300
  children: [{ tagName: "slot" }],
293
301
  };
294
302
  if (elementRefs.length > 0 && layout) {
295
- (layoutDoc as Record<string, unknown>).$elements = [
296
- ...elementRefs,
297
- ...(((layoutDoc as Record<string, unknown>).$elements as JxElement[]) ?? []),
298
- ];
303
+ prependElementRefs(layoutDoc, elementRefs);
299
304
  }
300
305
  classesStripped += stripClasses(layoutDoc);
301
306
  await io.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
@@ -304,6 +309,16 @@ export async function emitMultiPageProject({
304
309
  return { classesStripped, files, projectJson: projectText };
305
310
  }
306
311
 
312
+ /** Put the component registrations ahead of whatever `$elements` the document already declares. */
313
+ function prependElementRefs(doc: JxElement, refs: JxElement[]): void {
314
+ /* SAFETY: `$elements` is the document schema's registration list, an array of element entries
315
+ or specifier strings. The pages and layout reaching emit are built by the import pipeline
316
+ (toJx, detectLayout, componentize, the AI rename pass), none of which writes anything else
317
+ under that key. */
318
+ const existing = doc.$elements as (JxElement | string)[] | undefined;
319
+ doc.$elements = [...refs, ...(existing ?? [])];
320
+ }
321
+
307
322
  /**
308
323
  * The state key a whole-string `${state.x}` names, or null.
309
324
  *
@@ -318,14 +333,14 @@ function stateKey(text: string): string | undefined {
318
333
  }
319
334
 
320
335
  function extractStateDefaults(node: JxElement | string, out: Record<string, string>) {
321
- if (typeof node === "string") {
336
+ if (isString(node)) {
322
337
  const key = stateKey(node);
323
338
  if (key && !(key in out)) {
324
339
  out[key] = "";
325
340
  }
326
341
  return;
327
342
  }
328
- if (typeof node.textContent === "string") {
343
+ if (isString(node.textContent)) {
329
344
  const key = stateKey(node.textContent);
330
345
  if (key && !(key in out)) {
331
346
  out[key] = "";
@@ -333,7 +348,7 @@ function extractStateDefaults(node: JxElement | string, out: Record<string, stri
333
348
  }
334
349
  if (node.attributes) {
335
350
  for (const v of Object.values(node.attributes)) {
336
- if (typeof v === "string") {
351
+ if (isString(v)) {
337
352
  const key = stateKey(v);
338
353
  if (key && !(key in out)) {
339
354
  out[key] = "";
@@ -343,7 +358,7 @@ function extractStateDefaults(node: JxElement | string, out: Record<string, stri
343
358
  }
344
359
  if (Array.isArray(node.children)) {
345
360
  for (const child of node.children) {
346
- extractStateDefaults(child as JxElement | string, out);
361
+ extractStateDefaults(child, out);
347
362
  }
348
363
  }
349
364
  }
@@ -1,3 +1,4 @@
1
+ import { isString } from "@jxsuite/schema/guards";
1
2
  import type { JxElement } from "@jxsuite/schema/types";
2
3
 
3
4
  export interface LayoutResult {
@@ -12,7 +13,7 @@ export interface LayoutResult {
12
13
  * values. Used to detect structurally identical subtrees across pages.
13
14
  */
14
15
  export function hashSubtree(node: JxElement | string): string {
15
- if (typeof node === "string") {
16
+ if (isString(node)) {
16
17
  return `#text`;
17
18
  }
18
19
 
@@ -21,7 +22,7 @@ export function hashSubtree(node: JxElement | string): string {
21
22
 
22
23
  if (Array.isArray(node.children)) {
23
24
  for (const child of node.children) {
24
- childHashes.push(hashSubtree(child as JxElement | string));
25
+ childHashes.push(hashSubtree(child));
25
26
  }
26
27
  }
27
28
 
@@ -38,10 +39,10 @@ export function hashSubtree(node: JxElement | string): string {
38
39
  * headers/footers, not fuzzy matches.
39
40
  */
40
41
  export function treesEqual(a: JxElement | string, b: JxElement | string): boolean {
41
- if (typeof a === "string" && typeof b === "string") {
42
+ if (isString(a) && isString(b)) {
42
43
  return a === b;
43
44
  }
44
- if (typeof a === "string" || typeof b === "string") {
45
+ if (isString(a) || isString(b)) {
45
46
  return false;
46
47
  }
47
48
 
@@ -60,7 +61,7 @@ export function treesEqual(a: JxElement | string, b: JxElement | string): boolea
60
61
  }
61
62
 
62
63
  for (let i = 0; i < aChildren.length; i++) {
63
- if (!treesEqual(aChildren[i] as JxElement | string, bChildren[i] as JxElement | string)) {
64
+ if (!treesEqual(aChildren[i]!, bChildren[i]!)) {
64
65
  return false;
65
66
  }
66
67
  }
@@ -83,9 +84,7 @@ export function detectLayout(pages: Map<string, JxElement>): LayoutResult | null
83
84
  }
84
85
 
85
86
  const entries = [...pages.entries()];
86
- const allRoots = entries.map(([, doc]) =>
87
- Array.isArray(doc.children) ? (doc.children as (JxElement | string)[]) : [],
88
- );
87
+ const allRoots = entries.map(([, doc]) => (Array.isArray(doc.children) ? doc.children : []));
89
88
  const firstRoot = allRoots[0]!;
90
89
 
91
90
  // Find shared prefix (header elements)
@@ -129,20 +128,20 @@ export function detectLayout(pages: Map<string, JxElement>): LayoutResult | null
129
128
 
130
129
  const layout: JxElement = {
131
130
  tagName: "div",
132
- children: [...(headerChildren as JxElement[]), slot, ...(footerChildren as JxElement[])],
131
+ children: [...headerChildren, slot, ...footerChildren],
133
132
  };
134
133
 
135
134
  // Strip shared elements from each page
136
135
  const strippedPages = new Map<string, JxElement>();
137
136
  for (const [route, doc] of entries) {
138
- const children = Array.isArray(doc.children) ? (doc.children as (JxElement | string)[]) : [];
137
+ const children = Array.isArray(doc.children) ? doc.children : [];
139
138
 
140
139
  const endIdx = sharedSuffixLen > 0 ? children.length - sharedSuffixLen : children.length;
141
140
  const uniqueChildren = children.slice(sharedPrefixLen, endIdx);
142
141
 
143
142
  strippedPages.set(route, {
144
143
  ...doc,
145
- children: uniqueChildren as JxElement[],
144
+ children: uniqueChildren,
146
145
  $layout: "layouts/base.json",
147
146
  });
148
147
  }
package/src/pipeline.ts CHANGED
@@ -496,7 +496,7 @@ export async function runImportPipeline(
496
496
  fullPageScreenshots: referenceFullPage,
497
497
  breakpoints: breakpointPolicy,
498
498
  onProgress: (msg) => progress("crawl", msg.trim()),
499
- ...(signal === undefined ? {} : { signal }),
499
+ signal,
500
500
  });
501
501
 
502
502
  progress(
package/src/run.ts CHANGED
@@ -16,6 +16,7 @@ import { createLocalIo } from "./io.ts";
16
16
  import { runImportPipeline } from "./pipeline.ts";
17
17
  import type { BreakpointPolicy } from "./breakpoint-plan.ts";
18
18
  import type { ImportPhase, ImportProgressEvent } from "./pipeline.ts";
19
+ import type { VerifyOptions } from "./verify.ts";
19
20
  import type { Browser } from "puppeteer-core";
20
21
 
21
22
  export type { ImportPhase, ImportProgressEvent } from "./pipeline.ts";
@@ -65,6 +66,15 @@ export interface ImportSiteOptions {
65
66
  signal?: AbortSignal;
66
67
  }
67
68
 
69
+ /** One page's verify outcome, as counts rather than the verifier's full lists. */
70
+ export interface VerifiedPageSummary {
71
+ route: string;
72
+ fidelity: number;
73
+ consoleErrors: number;
74
+ failedRequests: number;
75
+ error?: string;
76
+ }
77
+
68
78
  export interface ImportSiteResult {
69
79
  outDir: string;
70
80
  pages: { route: string; title: string; nodeCount: number }[];
@@ -92,13 +102,7 @@ export interface ImportSiteResult {
92
102
  * The counts alongside it are what a percentage cannot say: a page that 404s on fifteen images
93
103
  * scores badly, and only `failedRequests` says why.
94
104
  */
95
- pages: {
96
- route: string;
97
- fidelity: number;
98
- consoleErrors: number;
99
- failedRequests: number;
100
- error?: string;
101
- }[];
105
+ pages: VerifiedPageSummary[];
102
106
  } | null;
103
107
  warnings: string[];
104
108
  }
@@ -250,15 +254,18 @@ export async function importSite(
250
254
  for (const [route, ref] of references) {
251
255
  verifyPages.set(route, { sourceUrl: ref.sourceUrl, screenshot: Buffer.from(ref.screenshot) });
252
256
  }
253
- const verifyResult = await verifyProject({
257
+ const verifyOptions: VerifyOptions = {
254
258
  projectDir: outDir,
255
259
  pages: verifyPages,
256
260
  threshold: verifyThreshold,
257
261
  minFidelity: verifyMinFidelity,
258
262
  fullPage: verifyFullPage,
259
263
  onProgress: (msg) => progress("verify", msg),
260
- ...(browser === undefined ? {} : { browser }),
261
- });
264
+ };
265
+ if (browser !== undefined) {
266
+ verifyOptions.browser = browser;
267
+ }
268
+ const verifyResult = await verifyProject(verifyOptions);
262
269
  /*
263
270
  * Build errors were logged inside the verifier and dropped there. They are warnings on the
264
271
  * import as a whole: a project that did not compile is not a clone of anything, however the
@@ -288,13 +295,7 @@ export async function importSite(
288
295
  minFidelity: verifyMinFidelity,
289
296
  buildErrors: verifyResult.buildErrors,
290
297
  pages: verifyResult.pages.map((page) => {
291
- const entry: {
292
- route: string;
293
- fidelity: number;
294
- consoleErrors: number;
295
- failedRequests: number;
296
- error?: string;
297
- } = {
298
+ const entry: VerifiedPageSummary = {
298
299
  route: page.route,
299
300
  fidelity: page.fidelity,
300
301
  consoleErrors: page.consoleErrors.length,
@@ -322,8 +323,11 @@ function throwIfAborted(signal?: AbortSignal): void {
322
323
  * absent `signal` and the pipeline would reject it. Copying present keys is also what keeps every
323
324
  * default in ONE place — restating `maxPages = 25` here is how the CLI and the cloud drift apart.
324
325
  */
325
- function pick<T extends object, K extends keyof T>(source: T, keys: readonly K[]): Pick<T, K> {
326
- const out = {} as Pick<T, K>;
326
+ function pick<T extends object, K extends keyof T>(
327
+ source: T,
328
+ keys: readonly K[],
329
+ ): Partial<Pick<T, K>> {
330
+ const out: Partial<Pick<T, K>> = {};
327
331
  for (const key of keys) {
328
332
  if (source[key] !== undefined) {
329
333
  out[key] = source[key];
@@ -24,6 +24,7 @@
24
24
  * @docs studio/projects/create
25
25
  */
26
26
 
27
+ import { isNonNullObject } from "@jxsuite/schema/guards";
27
28
  import type { JxElement } from "@jxsuite/schema/types";
28
29
 
29
30
  /** The two spellings a class can arrive in — `attributes.class` from HTML, `className` from Jx. */
@@ -36,22 +37,22 @@ const CLASS_KEYS = ["class", "className"] as const;
36
37
  * @returns {number} How many class names were removed, for the progress line.
37
38
  */
38
39
  export function stripClasses(node: JxElement | string): number {
39
- if (typeof node !== "object" || node === null) {
40
+ if (!isNonNullObject(node)) {
40
41
  return 0;
41
42
  }
42
43
 
43
44
  let removed = 0;
44
45
 
45
- if ("className" in node && node.className !== undefined) {
46
+ if (node.className !== undefined) {
46
47
  delete node.className;
47
48
  removed += 1;
48
49
  }
49
50
 
50
51
  const { attributes } = node;
51
- if (attributes && typeof attributes === "object") {
52
+ if (isNonNullObject(attributes)) {
52
53
  for (const key of CLASS_KEYS) {
53
54
  if (key in attributes) {
54
- delete (attributes as Record<string, unknown>)[key];
55
+ delete attributes[key];
55
56
  removed += 1;
56
57
  }
57
58
  }
@@ -65,7 +66,7 @@ export function stripClasses(node: JxElement | string): number {
65
66
 
66
67
  if (Array.isArray(node.children)) {
67
68
  for (const child of node.children) {
68
- removed += stripClasses(child as JxElement | string);
69
+ removed += stripClasses(child);
69
70
  }
70
71
  }
71
72
 
@@ -256,6 +256,9 @@ export async function captureStyles(page: ImportPage): Promise<StyleCaptureResul
256
256
  }
257
257
  collectMediaRules(rule.cssRules);
258
258
  } else if ("cssRules" in rule) {
259
+ /* SAFETY: every CSSOM rule interface that has a `cssRules` member (the grouping rules,
260
+ CSSKeyframesRule, and a nesting CSSStyleRule) types it as a CSSRuleList, which is the
261
+ only member read here. */
259
262
  collectMediaRules((rule as CSSGroupingRule).cssRules);
260
263
  }
261
264
  }
@@ -285,6 +288,8 @@ export async function captureStyles(page: ImportPage): Promise<StyleCaptureResul
285
288
  }
286
289
  if ("cssRules" in rule) {
287
290
  try {
291
+ /* SAFETY: every CSSOM rule interface that has a `cssRules` member types it as a
292
+ CSSRuleList, which is the only member read here. */
288
293
  collectCustomProps((rule as CSSGroupingRule).cssRules);
289
294
  } catch {
290
295
  /* Skip */
package/src/style-diff.ts CHANGED
@@ -88,10 +88,7 @@ function maybeNumeric(value: string): string | number {
88
88
  * Diff a single element's captured styles against its tagName's UA defaults. Returns only the
89
89
  * declarations that represent intentional styling.
90
90
  */
91
- export function diffStyles(
92
- captured: Record<string, string>,
93
- uaDefaults: Record<string, string>,
94
- ): Record<string, string | number> {
91
+ export function diffStyles(captured: Record<string, string>, uaDefaults: Record<string, string>) {
95
92
  const result: Record<string, string | number> = {};
96
93
 
97
94
  for (const [prop, value] of Object.entries(captured)) {