@jxsuite/import 0.39.7 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/package.json +7 -5
- package/src/asset-collect.ts +9 -3
- package/src/asset-download.ts +8 -3
- package/src/asset-rewrite.ts +7 -1
- package/src/breakpoint-plan.ts +297 -0
- package/src/cli.ts +106 -2
- package/src/componentize.ts +13 -0
- package/src/crawl.ts +40 -2
- package/src/emit.ts +104 -11
- package/src/index.ts +21 -3
- package/src/media-extract.ts +51 -75
- package/src/run.ts +227 -12
- package/src/srcset.ts +32 -0
- package/src/strip-classes.ts +73 -0
- package/src/verify.ts +165 -13
package/src/run.ts
CHANGED
|
@@ -5,13 +5,21 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync } from "node:fs";
|
|
8
|
-
import { isAbsolute } from "node:path";
|
|
8
|
+
import { isAbsolute, join } from "node:path";
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
9
10
|
import { capturePage, closeBrowser, launchBrowser } from "./capture.ts";
|
|
10
11
|
import { convertToJx } from "./to-jx.ts";
|
|
11
12
|
import { emitMultiPageProject } from "./emit.ts";
|
|
12
13
|
import { captureStyles } from "./style-capture.ts";
|
|
13
|
-
import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
|
|
14
14
|
import { extractMedia } from "./media-extract.ts";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_BREAKPOINT_POLICY,
|
|
17
|
+
analyzeMediaQueries,
|
|
18
|
+
planBreakpoints,
|
|
19
|
+
skippedWidthQueries,
|
|
20
|
+
} from "./breakpoint-plan.ts";
|
|
21
|
+
import type { BreakpointPolicy } from "./breakpoint-plan.ts";
|
|
22
|
+
import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
|
|
15
23
|
import { applyStylesToTree } from "./apply-styles.ts";
|
|
16
24
|
import { collectAssets } from "./asset-collect.ts";
|
|
17
25
|
import { downloadAssets } from "./asset-download.ts";
|
|
@@ -34,6 +42,14 @@ export interface ImportSiteOptions {
|
|
|
34
42
|
maxPages?: number;
|
|
35
43
|
/** Skip styles/assets for pages above this node count (default 5000). */
|
|
36
44
|
maxNodesPerPage?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Which of the site's declared breakpoints the project keeps (default: three, evenly spaced).
|
|
47
|
+
*
|
|
48
|
+
* A real site declares as many breakpoints as it has accumulated frameworks; nine `$media`
|
|
49
|
+
* entries is nine canvas sizes in Studio and nine columns in every style editor, and nobody
|
|
50
|
+
* authors against that. See `breakpoint-plan.ts`.
|
|
51
|
+
*/
|
|
52
|
+
breakpoints?: BreakpointPolicy;
|
|
37
53
|
/** Capture computed styles (default true). */
|
|
38
54
|
styles?: boolean;
|
|
39
55
|
/** Download and rewrite assets (default true). */
|
|
@@ -46,8 +62,14 @@ export interface ImportSiteOptions {
|
|
|
46
62
|
componentize?: false | { minInstances?: number; minDepth?: number };
|
|
47
63
|
/** LLM refinement of component/prop names; false/undefined to skip. */
|
|
48
64
|
ai?: false | { apiKey: string; baseUrl?: string | undefined; model?: string | undefined };
|
|
49
|
-
/**
|
|
50
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Build + screenshot-diff the emitted project against the original; false to skip.
|
|
67
|
+
*
|
|
68
|
+
* `threshold` is pixelmatch's per-pixel COLOUR tolerance, not a bar; `minFidelity` is the bar,
|
|
69
|
+
* and the run reports `passed` against it. `fullPage` compares the whole scrollable page (default
|
|
70
|
+
* true) rather than the first viewport.
|
|
71
|
+
*/
|
|
72
|
+
verify?: false | { threshold?: number; minFidelity?: number; fullPage?: boolean | undefined };
|
|
51
73
|
/** Explicit browser binary (wins over CHROME_PATH and PATH discovery). */
|
|
52
74
|
chromePath?: string;
|
|
53
75
|
/** Aborts between phases (and between crawled pages). */
|
|
@@ -55,6 +77,7 @@ export interface ImportSiteOptions {
|
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
export type ImportPhase =
|
|
80
|
+
| "seed"
|
|
58
81
|
| "launch"
|
|
59
82
|
| "capture"
|
|
60
83
|
| "crawl"
|
|
@@ -72,16 +95,75 @@ export interface ImportProgressEvent {
|
|
|
72
95
|
message: string;
|
|
73
96
|
current?: number;
|
|
74
97
|
total?: number;
|
|
98
|
+
/**
|
|
99
|
+
* The project root, on the `seed` event alone.
|
|
100
|
+
*
|
|
101
|
+
* The destination exists and holds a valid `project.json` from that moment, which is what lets a
|
|
102
|
+
* host OPEN it and watch the rest of the run land in its own file tree — rather than staring at a
|
|
103
|
+
* welcome screen for the several minutes a crawl takes.
|
|
104
|
+
*/
|
|
105
|
+
root?: string;
|
|
75
106
|
}
|
|
76
107
|
|
|
77
108
|
export interface ImportSiteResult {
|
|
78
109
|
outDir: string;
|
|
79
110
|
pages: { route: string; title: string; nodeCount: number }[];
|
|
80
111
|
fileCount: number;
|
|
81
|
-
verify: {
|
|
112
|
+
verify: {
|
|
113
|
+
averageFidelity: number;
|
|
114
|
+
reportDir: string;
|
|
115
|
+
/**
|
|
116
|
+
* Whether the run met its bar — it built cleanly, every page rendered, and the average reached
|
|
117
|
+
* `minFidelity`. Nothing about verify could fail before this existed: a clone scoring 8%
|
|
118
|
+
* finished exactly like one scoring 95% (issue #232).
|
|
119
|
+
*/
|
|
120
|
+
passed: boolean;
|
|
121
|
+
/** The bar `passed` was measured against. */
|
|
122
|
+
minFidelity: number;
|
|
123
|
+
/** Errors the compiler reported building the project — recorded, and now also enforced. */
|
|
124
|
+
buildErrors: string[];
|
|
125
|
+
/**
|
|
126
|
+
* Per page, because the average cannot name one.
|
|
127
|
+
*
|
|
128
|
+
* "Average fidelity 84%" is a fact nobody can act on; "the pricing page renders at 61%" is a
|
|
129
|
+
* decision — retry it, patch it by hand, or accept it. The verifier computed both and only the
|
|
130
|
+
* average was reported, so the actionable half was thrown away one level below the caller.
|
|
131
|
+
*
|
|
132
|
+
* The counts alongside it are what a percentage cannot say: a page that 404s on fifteen images
|
|
133
|
+
* scores badly, and only `failedRequests` says why.
|
|
134
|
+
*/
|
|
135
|
+
pages: {
|
|
136
|
+
route: string;
|
|
137
|
+
fidelity: number;
|
|
138
|
+
consoleErrors: number;
|
|
139
|
+
failedRequests: number;
|
|
140
|
+
error?: string;
|
|
141
|
+
}[];
|
|
142
|
+
} | null;
|
|
82
143
|
warnings: string[];
|
|
83
144
|
}
|
|
84
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The viewport the base capture runs at, and therefore the project's base width.
|
|
148
|
+
*
|
|
149
|
+
* It is `capture.ts`'s own default; naming it here is what lets the emitted `$media` carry a `"--"`
|
|
150
|
+
* base entry that agrees with the styles actually sampled. Without one, Studio's canvas falls back
|
|
151
|
+
* to 320px — narrower than every breakpoint an import discovers.
|
|
152
|
+
*/
|
|
153
|
+
const CAPTURE_WIDTH = 1440;
|
|
154
|
+
|
|
155
|
+
/** The one file the seed writes, and the only thing the empty-directory guard tolerates. */
|
|
156
|
+
const PROJECT_FILE = "project.json";
|
|
157
|
+
|
|
158
|
+
/** A placeholder project name from the URL, replaced by the page title at emit. */
|
|
159
|
+
function seedName(url: string): string {
|
|
160
|
+
try {
|
|
161
|
+
return new URL(url).hostname.replace(/^www\./, "") || "Imported Site";
|
|
162
|
+
} catch {
|
|
163
|
+
return "Imported Site";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
85
167
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
86
168
|
if (signal?.aborted) {
|
|
87
169
|
throw new Error("Import aborted");
|
|
@@ -154,6 +236,7 @@ export async function importSite(
|
|
|
154
236
|
verify = false,
|
|
155
237
|
chromePath,
|
|
156
238
|
signal,
|
|
239
|
+
breakpoints: breakpointPolicy = DEFAULT_BREAKPOINT_POLICY,
|
|
157
240
|
} = options;
|
|
158
241
|
|
|
159
242
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
@@ -162,7 +245,11 @@ export async function importSite(
|
|
|
162
245
|
if (!isAbsolute(outDir)) {
|
|
163
246
|
throw new Error(`outDir must be an absolute path, got "${outDir}"`);
|
|
164
247
|
}
|
|
165
|
-
|
|
248
|
+
/* The guard admits the run's OWN seed and nothing else. It has to: the destination is created and
|
|
249
|
+
given a `project.json` before the browser launches, so a host can open the project and watch the
|
|
250
|
+
rest of the run arrive in its file tree — and a guard that could not tell that seed from a
|
|
251
|
+
stranger's files would refuse every import at its second statement. */
|
|
252
|
+
if (existsSync(outDir) && readdirSync(outDir).some((entry) => entry !== PROJECT_FILE)) {
|
|
166
253
|
throw new Error(`Directory "${outDir}" is not empty`);
|
|
167
254
|
}
|
|
168
255
|
|
|
@@ -179,8 +266,11 @@ export async function importSite(
|
|
|
179
266
|
progress(phase, `⚠ ${message}`);
|
|
180
267
|
};
|
|
181
268
|
const verifyThreshold = verify === false ? 0.15 : (verify.threshold ?? 0.15);
|
|
269
|
+
const verifyMinFidelity = verify === false ? 0 : (verify.minFidelity ?? 0);
|
|
270
|
+
const verifyFullPage = verify === false ? true : (verify.fullPage ?? true);
|
|
182
271
|
|
|
183
272
|
try {
|
|
273
|
+
await seedProject();
|
|
184
274
|
if (maxDepth === 0) {
|
|
185
275
|
return await runSinglePage();
|
|
186
276
|
}
|
|
@@ -189,6 +279,25 @@ export async function importSite(
|
|
|
189
279
|
await closeBrowser();
|
|
190
280
|
}
|
|
191
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Create the destination and give it a `project.json` valid enough to open.
|
|
284
|
+
*
|
|
285
|
+
* The emit phase rewrites this file completely, so nothing here is a guess at what the import
|
|
286
|
+
* will find — it is the minimum that makes the directory a PROJECT, so the caller can open it now
|
|
287
|
+
* instead of at the end. Everything a crawl writes then lands in a file tree somebody is
|
|
288
|
+
* watching.
|
|
289
|
+
*/
|
|
290
|
+
async function seedProject(): Promise<void> {
|
|
291
|
+
await mkdir(outDir, { recursive: true });
|
|
292
|
+
const seed = { name: seedName(url), imports: {}, images: { optimize: false } };
|
|
293
|
+
await Bun.write(join(outDir, PROJECT_FILE), `${JSON.stringify(seed, null, 2)}\n`);
|
|
294
|
+
onProgress?.({
|
|
295
|
+
message: `Created ${outDir}`,
|
|
296
|
+
phase: "seed",
|
|
297
|
+
root: outDir,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
192
301
|
// ─── Single-page mode: Phase 0-2 pipeline, no crawl overhead ────────────────
|
|
193
302
|
async function runSinglePage(): Promise<ImportSiteResult> {
|
|
194
303
|
progress("launch", "Launching browser...");
|
|
@@ -250,11 +359,13 @@ export async function importSite(
|
|
|
250
359
|
"styles",
|
|
251
360
|
`Extracting @media breakpoints (${styleResult.mediaQueries.length} queries found)...`,
|
|
252
361
|
);
|
|
362
|
+
reportBreakpointPlan(styleResult.mediaQueries);
|
|
253
363
|
const media = await extractMedia(
|
|
254
364
|
capture.page,
|
|
255
365
|
styleResult.elements,
|
|
256
366
|
styleResult.uaDefaults,
|
|
257
367
|
styleResult.mediaQueries,
|
|
368
|
+
{ policy: breakpointPolicy },
|
|
258
369
|
);
|
|
259
370
|
const bpCount = Object.keys(media.breakpoints).length;
|
|
260
371
|
if (bpCount > 0) {
|
|
@@ -338,7 +449,12 @@ export async function importSite(
|
|
|
338
449
|
if (verify !== false) {
|
|
339
450
|
progress("verify", "Capturing reference screenshot...");
|
|
340
451
|
const { captureReferenceScreenshot } = await import("./verify.ts");
|
|
341
|
-
referenceScreenshot = await captureReferenceScreenshot(
|
|
452
|
+
referenceScreenshot = await captureReferenceScreenshot(
|
|
453
|
+
capture.page,
|
|
454
|
+
undefined,
|
|
455
|
+
undefined,
|
|
456
|
+
verifyFullPage,
|
|
457
|
+
);
|
|
342
458
|
}
|
|
343
459
|
|
|
344
460
|
await capture.page.close();
|
|
@@ -354,19 +470,20 @@ export async function importSite(
|
|
|
354
470
|
|
|
355
471
|
throwIfAborted(signal);
|
|
356
472
|
progress("emit", "Writing project...");
|
|
357
|
-
const { files } = await emitMultiPageProject({
|
|
473
|
+
const { files, classesStripped } = await emitMultiPageProject({
|
|
358
474
|
outDir,
|
|
359
475
|
title: capture.title,
|
|
360
476
|
pages: pageMap,
|
|
361
477
|
sourceUrl: url,
|
|
362
478
|
breakpoints,
|
|
479
|
+
baseWidth: CAPTURE_WIDTH,
|
|
363
480
|
componentizeOptions: precomputedComponents ? false : componentizeOptions,
|
|
364
481
|
precomputedComponents,
|
|
365
482
|
fontFaceRules,
|
|
366
483
|
fontRewriteMap,
|
|
367
484
|
styleTokens,
|
|
368
485
|
});
|
|
369
|
-
|
|
486
|
+
reportEmit(files.length, classesStripped);
|
|
370
487
|
|
|
371
488
|
let verifySummary: ImportSiteResult["verify"] = null;
|
|
372
489
|
if (verify !== false && referenceScreenshot) {
|
|
@@ -403,6 +520,9 @@ export async function importSite(
|
|
|
403
520
|
respectRobots,
|
|
404
521
|
noScroll: !scroll,
|
|
405
522
|
captureScreenshots: verify !== false,
|
|
523
|
+
// The reference and the render must be framed the same way, or the diff pads one of them.
|
|
524
|
+
fullPageScreenshots: verifyFullPage,
|
|
525
|
+
breakpoints: breakpointPolicy,
|
|
406
526
|
onProgress: (msg) => progress("crawl", msg.trim()),
|
|
407
527
|
...(signal === undefined ? {} : { signal }),
|
|
408
528
|
});
|
|
@@ -456,20 +576,21 @@ export async function importSite(
|
|
|
456
576
|
throwIfAborted(signal);
|
|
457
577
|
progress("emit", "Writing project...");
|
|
458
578
|
const title = result.pages[0]?.title || new URL(url).hostname;
|
|
459
|
-
const { files } = await emitMultiPageProject({
|
|
579
|
+
const { files, classesStripped } = await emitMultiPageProject({
|
|
460
580
|
outDir,
|
|
461
581
|
title,
|
|
462
582
|
sourceUrl: url,
|
|
463
583
|
pages: pageMap,
|
|
464
584
|
layout,
|
|
465
585
|
breakpoints: result.breakpoints,
|
|
586
|
+
baseWidth: CAPTURE_WIDTH,
|
|
466
587
|
componentizeOptions: precomputedComponents ? false : componentizeOptions,
|
|
467
588
|
precomputedComponents,
|
|
468
589
|
fontFaceRules: result.fontFaceRules.length > 0 ? result.fontFaceRules : undefined,
|
|
469
590
|
fontRewriteMap: result.fontRewriteMap.size > 0 ? result.fontRewriteMap : undefined,
|
|
470
591
|
styleTokens: result.styleTokens,
|
|
471
592
|
});
|
|
472
|
-
|
|
593
|
+
reportEmit(files.length, classesStripped);
|
|
473
594
|
|
|
474
595
|
let verifySummary: ImportSiteResult["verify"] = null;
|
|
475
596
|
if (verify !== false) {
|
|
@@ -500,6 +621,60 @@ export async function importSite(
|
|
|
500
621
|
};
|
|
501
622
|
}
|
|
502
623
|
|
|
624
|
+
/** One emit line, naming what the strip pass removed — silence would read as "nothing to do". */
|
|
625
|
+
function reportEmit(fileCount: number, classesStripped: number): void {
|
|
626
|
+
const classes =
|
|
627
|
+
classesStripped > 0
|
|
628
|
+
? `, stripped ${classesStripped} source class name${classesStripped === 1 ? "" : "s"}`
|
|
629
|
+
: "";
|
|
630
|
+
progress("emit", `Wrote ${fileCount} files${classes}`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Say which breakpoints the project is getting, and where the others went.
|
|
635
|
+
*
|
|
636
|
+
* The plan is a decision made ON THE AUTHOR'S BEHALF — nine declared widths become three — so it
|
|
637
|
+
* has to be visible. "9 breakpoints found, keeping 3" with the fold spelled out is the difference
|
|
638
|
+
* between a project that mysteriously has different breakpoints from its source and one whose
|
|
639
|
+
* import said so at the time.
|
|
640
|
+
*/
|
|
641
|
+
function reportBreakpointPlan(mediaQueries: readonly string[]): void {
|
|
642
|
+
const skipped = skippedWidthQueries(mediaQueries);
|
|
643
|
+
if (skipped.length > 0) {
|
|
644
|
+
warn(
|
|
645
|
+
"styles",
|
|
646
|
+
`${skipped.length} width media ${skipped.length === 1 ? "query" : "queries"} could not be ` +
|
|
647
|
+
`read (only single-clause px min-width/max-width is supported): ${skipped.join(", ")}`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const discovered = analyzeMediaQueries(mediaQueries);
|
|
651
|
+
if (discovered.length === 0) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const { keep, fold } = planBreakpoints(discovered, breakpointPolicy);
|
|
655
|
+
if (keep.length === discovered.length) {
|
|
656
|
+
progress("styles", `Keeping all ${keep.length} declared breakpoints`);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const folded = new Map<string, string[]>();
|
|
660
|
+
for (const [from, to] of fold) {
|
|
661
|
+
if (from === to) {
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
folded.set(to, [...(folded.get(to) ?? []), from.replace(/^--/, "")]);
|
|
665
|
+
}
|
|
666
|
+
const detail = keep
|
|
667
|
+
.map((bp) => {
|
|
668
|
+
const into = folded.get(bp.name) ?? [];
|
|
669
|
+
return into.length > 0 ? `${bp.name} (+${into.join(", ")})` : bp.name;
|
|
670
|
+
})
|
|
671
|
+
.join(", ");
|
|
672
|
+
progress(
|
|
673
|
+
"styles",
|
|
674
|
+
`${discovered.length} breakpoints declared, keeping ${keep.length}: ${detail}`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
503
678
|
// ─── Phase 5: build + screenshot-diff against the original ─────────────────
|
|
504
679
|
async function runVerify(
|
|
505
680
|
verifyPages: Map<string, { sourceUrl: string; screenshot: Buffer | string }>,
|
|
@@ -512,17 +687,57 @@ export async function importSite(
|
|
|
512
687
|
projectDir: outDir,
|
|
513
688
|
pages: verifyPages,
|
|
514
689
|
threshold: verifyThreshold,
|
|
690
|
+
minFidelity: verifyMinFidelity,
|
|
691
|
+
fullPage: verifyFullPage,
|
|
515
692
|
onProgress: (msg) => progress("verify", msg),
|
|
516
693
|
...(browser === undefined ? {} : { browser }),
|
|
517
694
|
});
|
|
695
|
+
/*
|
|
696
|
+
* Build errors were logged inside the verifier and dropped there. They are warnings on the
|
|
697
|
+
* import as a whole: a project that did not compile is not a clone of anything, however the
|
|
698
|
+
* pixels happen to score.
|
|
699
|
+
*/
|
|
700
|
+
for (const error of verifyResult.buildErrors) {
|
|
701
|
+
warn("verify", `Build error: ${error}`);
|
|
702
|
+
}
|
|
518
703
|
for (const page of verifyResult.pages) {
|
|
519
704
|
const status = page.error ? `ERROR: ${page.error}` : `${page.fidelity}% fidelity`;
|
|
520
|
-
|
|
705
|
+
const misses =
|
|
706
|
+
page.failedRequests.length > 0 ? `, ${page.failedRequests.length} failed request(s)` : "";
|
|
707
|
+
progress("verify", `${page.route} — ${status}${misses}`);
|
|
521
708
|
}
|
|
522
709
|
progress("verify", `Average fidelity: ${verifyResult.averageFidelity}%`);
|
|
710
|
+
if (!verifyResult.passed) {
|
|
711
|
+
warn(
|
|
712
|
+
"verify",
|
|
713
|
+
`Verification failed: average fidelity ${verifyResult.averageFidelity}% ` +
|
|
714
|
+
`(minimum ${verifyMinFidelity}%). See ${verifyResult.reportDir}/report.json`,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
523
717
|
return {
|
|
524
718
|
averageFidelity: verifyResult.averageFidelity,
|
|
525
719
|
reportDir: verifyResult.reportDir,
|
|
720
|
+
passed: verifyResult.passed,
|
|
721
|
+
minFidelity: verifyMinFidelity,
|
|
722
|
+
buildErrors: verifyResult.buildErrors,
|
|
723
|
+
pages: verifyResult.pages.map((page) => {
|
|
724
|
+
const entry: {
|
|
725
|
+
route: string;
|
|
726
|
+
fidelity: number;
|
|
727
|
+
consoleErrors: number;
|
|
728
|
+
failedRequests: number;
|
|
729
|
+
error?: string;
|
|
730
|
+
} = {
|
|
731
|
+
route: page.route,
|
|
732
|
+
fidelity: page.fidelity,
|
|
733
|
+
consoleErrors: page.consoleErrors.length,
|
|
734
|
+
failedRequests: page.failedRequests.length,
|
|
735
|
+
};
|
|
736
|
+
if (page.error !== undefined) {
|
|
737
|
+
entry.error = page.error;
|
|
738
|
+
}
|
|
739
|
+
return entry;
|
|
740
|
+
}),
|
|
526
741
|
};
|
|
527
742
|
}
|
|
528
743
|
}
|
package/src/srcset.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where one `srcset` entry ends and the next begins.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because two passes have to agree about it: `asset-collect.ts` decides from it what
|
|
5
|
+
* gets DOWNLOADED, and `asset-rewrite.ts` reassembles the attribute from it. If they disagreed the
|
|
6
|
+
* importer would fetch one set of URLs and rewrite another.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A `srcset` separator is a comma that starts a NEW URL. A comma inside a URL never is.
|
|
11
|
+
*
|
|
12
|
+
* Splitting on every comma shreds any URL carrying commas in its own path, and Wix, Cloudinary and
|
|
13
|
+
* imgix all encode image transforms exactly that way — `.../fill/w_375,h_127,al_c,q_85/logo.png`
|
|
14
|
+
* became a dozen unfetchable fragments. On one Wix site that was 109 failed downloads, and because
|
|
15
|
+
* a failed download leaves the reference untouched, 46 `static.wixstatic.com` URLs stayed in the
|
|
16
|
+
* emitted page: the "clone" served its images from the host it had cloned (issue #231).
|
|
17
|
+
*
|
|
18
|
+
* The lookahead lists the ways a URL can begin — absolute, `data:`, protocol-relative, root- or
|
|
19
|
+
* dot-relative, or a bare `name.ext` — which is what distinguishes a separator from a transform
|
|
20
|
+
* parameter. No `g` flag: `String.split` needs none, and a sticky `lastIndex` shared across calls
|
|
21
|
+
* is a bug waiting to happen.
|
|
22
|
+
*/
|
|
23
|
+
export const SRCSET_SEPARATOR =
|
|
24
|
+
/,(?=\s*(?:https?:\/\/|data:|\/\/|\/|\.{1,2}\/|[A-Za-z0-9_-]+\.[A-Za-z]{2,5}[/?#\s]))/;
|
|
25
|
+
|
|
26
|
+
/** The URL of each entry in a `srcset`, descriptors (`2x`, `800w`) dropped. */
|
|
27
|
+
export function parseSrcset(srcset: string): string[] {
|
|
28
|
+
return srcset
|
|
29
|
+
.split(SRCSET_SEPARATOR)
|
|
30
|
+
.map((entry) => entry.trim().split(/\s+/)[0] ?? "")
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip the source site's CSS classes out of an imported tree.
|
|
3
|
+
*
|
|
4
|
+
* The importer never resolves a class to a style. Every visual fact it emits comes from
|
|
5
|
+
* `getComputedStyle` (`style-capture.ts` → `style-diff.ts` → `apply-styles.ts`), and the only CSS
|
|
6
|
+
* it writes is `public/assets/fonts.css`, built from `@font-face` rules alone — the original
|
|
7
|
+
* stylesheets are collected for their fonts and then discarded. So an imported `class="hero
|
|
8
|
+
* grid-cols-3 lg:pt-12"` names rules that do not exist in the emitted project: it is dead weight
|
|
9
|
+
* that survives into every page, layout and component a reader then has to edit around.
|
|
10
|
+
*
|
|
11
|
+
* It is worse than inert in one place. `componentize.ts` treats every attribute as a prop
|
|
12
|
+
* candidate, so a class that happens to differ between two instances of the same block becomes a
|
|
13
|
+
* `${state.class}` prop with a name nobody chose. That is handled at the source there; this module
|
|
14
|
+
* is what guarantees nothing reaches disk regardless.
|
|
15
|
+
*
|
|
16
|
+
* **This is not a claim that Jx is classless.** `className` is a first-class schema property
|
|
17
|
+
* (`specs/spec.md` §8.1) and the compiler emits generated classes for nested style rules (§9.2);
|
|
18
|
+
* neither changes. What is removed here is one specific thing: class names imported from a site
|
|
19
|
+
* whose stylesheets were not imported with them.
|
|
20
|
+
*
|
|
21
|
+
* `id`, `role` and `aria-*` are deliberately left alone — anchors and accessibility survive the
|
|
22
|
+
* round trip, and unlike a class they mean the same thing without the original CSS.
|
|
23
|
+
*
|
|
24
|
+
* @docs studio/projects/create
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
28
|
+
|
|
29
|
+
/** The two spellings a class can arrive in — `attributes.class` from HTML, `className` from Jx. */
|
|
30
|
+
const CLASS_KEYS = ["class", "className"] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Remove class names from `node` and every descendant, in place.
|
|
34
|
+
*
|
|
35
|
+
* @param {JxElement | string} node - A tree node; strings (text) pass through untouched.
|
|
36
|
+
* @returns {number} How many class names were removed, for the progress line.
|
|
37
|
+
*/
|
|
38
|
+
export function stripClasses(node: JxElement | string): number {
|
|
39
|
+
if (typeof node !== "object" || node === null) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let removed = 0;
|
|
44
|
+
|
|
45
|
+
if ("className" in node && node.className !== undefined) {
|
|
46
|
+
delete node.className;
|
|
47
|
+
removed += 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { attributes } = node;
|
|
51
|
+
if (attributes && typeof attributes === "object") {
|
|
52
|
+
for (const key of CLASS_KEYS) {
|
|
53
|
+
if (key in attributes) {
|
|
54
|
+
delete (attributes as Record<string, unknown>)[key];
|
|
55
|
+
removed += 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/* An `attributes` map emptied by the strip is deleted rather than left as `{}`: the emitted JSON
|
|
59
|
+
is read by a person, and a bare `"attributes": {}` on every node is the same noise the classes
|
|
60
|
+
were. `htmlToJx` never writes an empty one either, so this restores its own invariant. */
|
|
61
|
+
if (Object.keys(attributes).length === 0) {
|
|
62
|
+
delete node.attributes;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (Array.isArray(node.children)) {
|
|
67
|
+
for (const child of node.children) {
|
|
68
|
+
removed += stripClasses(child as JxElement | string);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return removed;
|
|
73
|
+
}
|