@jxsuite/import 0.40.8 → 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.
- package/package.json +6 -4
- package/src/ai-componentize.ts +49 -71
- package/src/apply-accordions.ts +32 -25
- package/src/apply-disclosures.ts +30 -40
- package/src/apply-popovers.ts +24 -29
- package/src/apply-styles.ts +6 -5
- package/src/asset-collect.ts +17 -20
- package/src/asset-download.ts +3 -3
- package/src/asset-rewrite.ts +28 -28
- package/src/breakpoint-plan.ts +7 -2
- package/src/capture.ts +11 -5
- package/src/cli.ts +4 -3
- package/src/componentize.ts +32 -37
- package/src/crawl.ts +2 -2
- package/src/css-tokens.ts +2 -1
- package/src/derived-geometry.ts +13 -14
- package/src/emit.ts +37 -22
- package/src/layout-detect.ts +10 -11
- package/src/pipeline.ts +1 -1
- package/src/run.ts +23 -19
- package/src/strip-classes.ts +6 -5
- package/src/style-capture.ts +5 -0
- package/src/style-diff.ts +1 -4
- package/src/to-jx.ts +13 -9
- package/src/toggle-algebra.ts +7 -4
- package/src/verify.ts +53 -40
package/src/componentize.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
87
|
-
collectLeafValues(
|
|
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
|
|
116
|
+
node: JxElement,
|
|
115
117
|
prefix: string,
|
|
116
118
|
varyingPaths: Set<string>,
|
|
117
119
|
nameMap: Map<string, string>,
|
|
118
|
-
): JxElement
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
children[head] = replacement;
|
|
178
178
|
} else {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 (
|
|
235
|
-
|
|
236
|
-
|
|
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 (
|
|
129
|
+
if (!isString(value)) {
|
|
129
130
|
continue;
|
|
130
131
|
}
|
|
131
132
|
// Already a var() reference
|
package/src/derived-geometry.ts
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
* imply, so their measurements are kept.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import
|
|
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:
|
|
53
|
-
return
|
|
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 (
|
|
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 {
|
|
79
|
-
* @param {
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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:
|
|
88
|
-
context:
|
|
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
|
|
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:
|
|
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 = { ...
|
|
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
|
|
176
|
-
const state = { ...derived, ...declared };
|
|
189
|
+
const state = { ...derived, ...comp.template.state };
|
|
177
190
|
if (Object.keys(state).length > 0) {
|
|
178
|
-
compDoc.state = state
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
361
|
+
extractStateDefaults(child, out);
|
|
347
362
|
}
|
|
348
363
|
}
|
|
349
364
|
}
|
package/src/layout-detect.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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 (
|
|
42
|
+
if (isString(a) && isString(b)) {
|
|
42
43
|
return a === b;
|
|
43
44
|
}
|
|
44
|
-
if (
|
|
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]
|
|
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: [...
|
|
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) ?
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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>(
|
|
326
|
-
|
|
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];
|
package/src/strip-classes.ts
CHANGED
|
@@ -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 (
|
|
40
|
+
if (!isNonNullObject(node)) {
|
|
40
41
|
return 0;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
let removed = 0;
|
|
44
45
|
|
|
45
|
-
if (
|
|
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
|
|
52
|
+
if (isNonNullObject(attributes)) {
|
|
52
53
|
for (const key of CLASS_KEYS) {
|
|
53
54
|
if (key in attributes) {
|
|
54
|
-
delete
|
|
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
|
|
69
|
+
removed += stripClasses(child);
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
package/src/style-capture.ts
CHANGED
|
@@ -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)) {
|