@jxsuite/import 0.40.0 → 0.40.2
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 +8 -30
- package/package.json +6 -4
- package/src/ai-componentize.ts +12 -2
- package/src/apply-accordions.ts +304 -0
- package/src/apply-disclosures.ts +275 -0
- package/src/apply-popovers.ts +298 -0
- package/src/apply-styles.ts +60 -11
- package/src/asset-collect.ts +2 -2
- package/src/asset-download.ts +10 -11
- package/src/asset-rewrite.ts +30 -6
- package/src/browser-local.ts +67 -0
- package/src/capture.ts +50 -61
- package/src/componentize.ts +35 -1
- package/src/crawl.ts +23 -17
- package/src/derived-geometry.ts +115 -0
- package/src/emit.ts +59 -34
- package/src/image-family.ts +227 -0
- package/src/index.ts +11 -5
- package/src/io.ts +52 -0
- package/src/media-extract.ts +75 -6
- package/src/pipeline.ts +673 -0
- package/src/run.ts +93 -503
- package/src/style-capture.ts +201 -162
- package/src/style-diff.ts +17 -4
- package/src/to-jx.ts +27 -1
- package/src/toggle-algebra.ts +167 -0
- package/src/verify.ts +1 -1
package/src/emit.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { join, dirname } from "node:path";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
3
1
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
4
2
|
import { componentize } from "./componentize.ts";
|
|
5
3
|
import { stripClasses } from "./strip-classes.ts";
|
|
6
4
|
import type { ComponentizeOptions, ComponentizeResult } from "./componentize.ts";
|
|
5
|
+
import type { ImportIo } from "./io.ts";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* The `$media` key the base width lives under — a number of CSS pixels, not a query.
|
|
@@ -21,14 +20,22 @@ function mediaKeyWidth(key: string): number {
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
export interface EmitResult {
|
|
24
|
-
/**
|
|
23
|
+
/** Project-relative, forward-slashed paths of every file written, in the order they were written. */
|
|
25
24
|
files: string[];
|
|
26
25
|
/** How many source-site class names were removed on the way out. */
|
|
27
26
|
classesStripped: number;
|
|
27
|
+
/**
|
|
28
|
+
* The exact `project.json` text that was written.
|
|
29
|
+
*
|
|
30
|
+
* Returned rather than left to be read back, because a caller with no filesystem cannot read it
|
|
31
|
+
* back at all: Jx Cloud commits the emitted file set to git and needs the configuration to name
|
|
32
|
+
* the project, and re-deriving it here would be a second writer on the same bytes.
|
|
33
|
+
*/
|
|
34
|
+
projectJson: string;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export interface EmitOptions {
|
|
31
|
-
|
|
38
|
+
io: ImportIo;
|
|
32
39
|
title: string;
|
|
33
40
|
document: JxElement;
|
|
34
41
|
sourceUrl: string;
|
|
@@ -41,7 +48,8 @@ export interface EmitOptions {
|
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
export interface MultiEmitOptions {
|
|
44
|
-
|
|
51
|
+
/** Where the emitted files go. See `io.ts` — a disk is one implementation, not the requirement. */
|
|
52
|
+
io: ImportIo;
|
|
45
53
|
title: string;
|
|
46
54
|
sourceUrl: string;
|
|
47
55
|
/** Map of route path (e.g. "pages/index.json") → Jx document. */
|
|
@@ -65,7 +73,7 @@ export interface MultiEmitOptions {
|
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
export async function emitProject({
|
|
68
|
-
|
|
76
|
+
io,
|
|
69
77
|
title,
|
|
70
78
|
document,
|
|
71
79
|
sourceUrl,
|
|
@@ -74,7 +82,7 @@ export async function emitProject({
|
|
|
74
82
|
componentizeOptions,
|
|
75
83
|
}: EmitOptions): Promise<EmitResult> {
|
|
76
84
|
return emitMultiPageProject({
|
|
77
|
-
|
|
85
|
+
io,
|
|
78
86
|
title,
|
|
79
87
|
sourceUrl,
|
|
80
88
|
pages: new Map([["pages/index.json", document]]),
|
|
@@ -85,7 +93,7 @@ export async function emitProject({
|
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
export async function emitMultiPageProject({
|
|
88
|
-
|
|
96
|
+
io,
|
|
89
97
|
title,
|
|
90
98
|
pages,
|
|
91
99
|
layout,
|
|
@@ -97,10 +105,12 @@ export async function emitMultiPageProject({
|
|
|
97
105
|
fontRewriteMap,
|
|
98
106
|
styleTokens,
|
|
99
107
|
}: MultiEmitOptions): Promise<EmitResult> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
/* Seeded even when nothing lands in them, so a host that opened the project at the seed event
|
|
109
|
+
sees the shape it expects rather than a directory that grows one folder at a time. A sink with
|
|
110
|
+
no directories declines by not implementing it. */
|
|
111
|
+
for (const dir of ["pages", "layouts", "components", "public"]) {
|
|
112
|
+
await io.mkdir?.(dir);
|
|
113
|
+
}
|
|
104
114
|
|
|
105
115
|
/*
|
|
106
116
|
* Only keys the project schema declares (#228). `title` and `description` are page-level keys and
|
|
@@ -156,10 +166,16 @@ export async function emitMultiPageProject({
|
|
|
156
166
|
tagName: comp.tagName,
|
|
157
167
|
};
|
|
158
168
|
|
|
159
|
-
|
|
160
|
-
|
|
169
|
+
/* Derived placeholders fill in for props the template interpolates but nobody declared;
|
|
170
|
+
a value the template DECLARED wins over them. This was an assignment over the spread, so
|
|
171
|
+
a component that carried its own `state` — the `active` index a tab group switches on —
|
|
172
|
+
lost it on the way out, and every interpolation referencing it went dead. */
|
|
173
|
+
const derived: Record<string, string> = {};
|
|
174
|
+
extractStateDefaults(comp.template, derived);
|
|
175
|
+
const declared = (comp.template.state ?? {}) as Record<string, unknown>;
|
|
176
|
+
const state = { ...derived, ...declared };
|
|
161
177
|
if (Object.keys(state).length > 0) {
|
|
162
|
-
compDoc.state = state
|
|
178
|
+
compDoc.state = state as Record<string, string>;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
componentFiles.set(fileName, compDoc);
|
|
@@ -207,10 +223,8 @@ export async function emitMultiPageProject({
|
|
|
207
223
|
fontCss = fontCss.replaceAll(pattern, (m) => byForm.get(m) ?? m);
|
|
208
224
|
}
|
|
209
225
|
}
|
|
210
|
-
const
|
|
211
|
-
await
|
|
212
|
-
const fontsCssPath = join(fontsDir, "fonts.css");
|
|
213
|
-
await Bun.write(fontsCssPath, fontCss);
|
|
226
|
+
const fontsCssPath = "public/assets/fonts.css";
|
|
227
|
+
await io.write(fontsCssPath, fontCss);
|
|
214
228
|
files.push(fontsCssPath);
|
|
215
229
|
|
|
216
230
|
// Add fonts.css to project head
|
|
@@ -223,9 +237,9 @@ export async function emitMultiPageProject({
|
|
|
223
237
|
});
|
|
224
238
|
}
|
|
225
239
|
|
|
226
|
-
const
|
|
227
|
-
await
|
|
228
|
-
files.push(
|
|
240
|
+
const projectText = `${JSON.stringify(projectJson, null, 2)}\n`;
|
|
241
|
+
await io.write("project.json", projectText);
|
|
242
|
+
files.push("project.json");
|
|
229
243
|
|
|
230
244
|
/*
|
|
231
245
|
* Classes go here, at the last possible moment, and that placement is the whole design.
|
|
@@ -245,8 +259,8 @@ export async function emitMultiPageProject({
|
|
|
245
259
|
|
|
246
260
|
// Write components
|
|
247
261
|
for (const [fileName, compDoc] of componentFiles) {
|
|
248
|
-
const compPath =
|
|
249
|
-
await
|
|
262
|
+
const compPath = `components/${fileName}`;
|
|
263
|
+
await io.write(compPath, `${JSON.stringify(compDoc, null, 2)}\n`);
|
|
250
264
|
files.push(compPath);
|
|
251
265
|
}
|
|
252
266
|
|
|
@@ -267,14 +281,12 @@ export async function emitMultiPageProject({
|
|
|
267
281
|
...((pageDoc.$elements as JxElement[]) ?? []),
|
|
268
282
|
] as JxElement[];
|
|
269
283
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await Bun.write(pagePath, `${JSON.stringify(pageDoc, null, 2)}\n`);
|
|
273
|
-
files.push(pagePath);
|
|
284
|
+
await io.write(route, `${JSON.stringify(pageDoc, null, 2)}\n`);
|
|
285
|
+
files.push(route);
|
|
274
286
|
}
|
|
275
287
|
|
|
276
288
|
// Write layout
|
|
277
|
-
const layoutPath =
|
|
289
|
+
const layoutPath = "layouts/base.json";
|
|
278
290
|
const layoutDoc: JxElement = layout ?? {
|
|
279
291
|
tagName: "div",
|
|
280
292
|
children: [{ tagName: "slot" }],
|
|
@@ -286,22 +298,35 @@ export async function emitMultiPageProject({
|
|
|
286
298
|
];
|
|
287
299
|
}
|
|
288
300
|
classesStripped += stripClasses(layoutDoc);
|
|
289
|
-
await
|
|
301
|
+
await io.write(layoutPath, `${JSON.stringify(layoutDoc, null, 2)}\n`);
|
|
290
302
|
files.push(layoutPath);
|
|
291
303
|
|
|
292
|
-
return { classesStripped, files };
|
|
304
|
+
return { classesStripped, files, projectJson: projectText };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The state key a whole-string `${state.x}` names, or null.
|
|
309
|
+
*
|
|
310
|
+
* The identifier check is the point. The capture used to be `([^}]+)`, so `${state.active !== 0}` —
|
|
311
|
+
* an expression, not a reference — minted a state entry literally called `active !== 0` beside the
|
|
312
|
+
* real one. Only a bare identifier is a key; anything else is an expression over state and has no
|
|
313
|
+
* default of its own to declare.
|
|
314
|
+
*/
|
|
315
|
+
function stateKey(text: string): string | undefined {
|
|
316
|
+
const key = text.match(/^\$\{state\.([^}]+)\}$/)?.[1];
|
|
317
|
+
return key !== undefined && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : undefined;
|
|
293
318
|
}
|
|
294
319
|
|
|
295
320
|
function extractStateDefaults(node: JxElement | string, out: Record<string, string>) {
|
|
296
321
|
if (typeof node === "string") {
|
|
297
|
-
const key = node
|
|
322
|
+
const key = stateKey(node);
|
|
298
323
|
if (key && !(key in out)) {
|
|
299
324
|
out[key] = "";
|
|
300
325
|
}
|
|
301
326
|
return;
|
|
302
327
|
}
|
|
303
328
|
if (typeof node.textContent === "string") {
|
|
304
|
-
const key = node.textContent
|
|
329
|
+
const key = stateKey(node.textContent);
|
|
305
330
|
if (key && !(key in out)) {
|
|
306
331
|
out[key] = "";
|
|
307
332
|
}
|
|
@@ -309,7 +334,7 @@ function extractStateDefaults(node: JxElement | string, out: Record<string, stri
|
|
|
309
334
|
if (node.attributes) {
|
|
310
335
|
for (const v of Object.values(node.attributes)) {
|
|
311
336
|
if (typeof v === "string") {
|
|
312
|
-
const key = v
|
|
337
|
+
const key = stateKey(v);
|
|
313
338
|
if (key && !(key in out)) {
|
|
314
339
|
out[key] = "";
|
|
315
340
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group an image's responsive derivatives back into one family, and pick the member to keep.
|
|
3
|
+
*
|
|
4
|
+
* A CMS publishes one photograph as a dozen files. WordPress alone emits `-300x200`, `-768x512`,
|
|
5
|
+
* `-1024x683`, `-1536x1025`, `-2048x1366` and `-scaled` beside the upload, and every one of them
|
|
6
|
+
* appears in the `srcset` of every `<img>` that uses it. The importer enqueued each candidate as an
|
|
7
|
+
* independent asset, so a clone of a 451-image site downloaded 2,446 files and 427 MB, and the
|
|
8
|
+
* emitted markup carried the whole derivative ladder forward.
|
|
9
|
+
*
|
|
10
|
+
* That is the wrong shape twice over. It is large, and it hands the compiler a `srcset` it did not
|
|
11
|
+
* build and cannot reason about — while `image-transform.ts` caps its own ladder at the source
|
|
12
|
+
* file's width, so an `<img src>` pointing at a `-300x200` derivative is a permanent quality
|
|
13
|
+
* ceiling rather than merely a wasted byte.
|
|
14
|
+
*
|
|
15
|
+
* So the importer keeps ONE member per family and lets the compiler regenerate sizes from it.
|
|
16
|
+
*
|
|
17
|
+
* Two rules make this safe on a real site:
|
|
18
|
+
*
|
|
19
|
+
* 1. **The key comes from the source URL, never from the written filename.** `sanitizeFilename`
|
|
20
|
+
* truncates a long name to 96 characters, which cuts the `-WxH` marker off the end — five
|
|
21
|
+
* resolutions of one image would land as five unrelated families.
|
|
22
|
+
* 2. **The winner is always a URL the page actually referenced.** 120 of the 451 families on the
|
|
23
|
+
* reference corpus have no undecorated member at all, because WordPress serves only
|
|
24
|
+
* `-scaled.jpg` once an upload passes its threshold. Synthesising the bare name and fetching it
|
|
25
|
+
* would 404 on those, and a failed download leaves the remote URL in the markup — the clone then
|
|
26
|
+
* serves images from the host it cloned. Taking the largest real candidate cannot fail that
|
|
27
|
+
* way.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { DiscoveredAsset } from "./asset-collect.ts";
|
|
31
|
+
|
|
32
|
+
/** Sources whose URLs are responsive variants of one image. A CSS background is not one. */
|
|
33
|
+
const FAMILY_SOURCES = new Set(["img-src", "img-srcset", "source-srcset", "picture-source"]);
|
|
34
|
+
|
|
35
|
+
/** `name-1024x683.jpg` → the `1024x683` marker, and where it starts. */
|
|
36
|
+
const DIMENSION_SUFFIX = /-(\d{1,5})x(\d{1,5})$/;
|
|
37
|
+
|
|
38
|
+
/** WordPress's marker for "the original was too big, this is the working copy". */
|
|
39
|
+
const SCALED_SUFFIX = /-scaled$/;
|
|
40
|
+
|
|
41
|
+
interface ParsedName {
|
|
42
|
+
/** The stem with any `-WxH` and `-scaled` markers removed. */
|
|
43
|
+
stem: string;
|
|
44
|
+
/** Pixel area of the `-WxH` marker, or null when there is none. */
|
|
45
|
+
area: number | null;
|
|
46
|
+
/** Whether the name carried `-scaled`. */
|
|
47
|
+
scaled: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitExtension(pathname: string): { base: string; ext: string } {
|
|
51
|
+
const slash = pathname.lastIndexOf("/");
|
|
52
|
+
const dot = pathname.lastIndexOf(".");
|
|
53
|
+
return dot > slash
|
|
54
|
+
? { base: pathname.slice(0, dot), ext: pathname.slice(dot) }
|
|
55
|
+
: { base: pathname, ext: "" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strip the derivative markers from a pathname's final segment.
|
|
60
|
+
*
|
|
61
|
+
* Order matters: WordPress writes `photo-scaled-768x512.jpg`, so the dimensions come off first and
|
|
62
|
+
* `-scaled` second. Doing it the other way leaves `-768x512` welded to the stem and splits the
|
|
63
|
+
* family.
|
|
64
|
+
*/
|
|
65
|
+
function parseName(pathname: string): ParsedName {
|
|
66
|
+
const { base } = splitExtension(pathname);
|
|
67
|
+
let stem = base;
|
|
68
|
+
let area: number | null = null;
|
|
69
|
+
|
|
70
|
+
const dimensions = stem.match(DIMENSION_SUFFIX);
|
|
71
|
+
if (dimensions) {
|
|
72
|
+
area = Number(dimensions[1]) * Number(dimensions[2]);
|
|
73
|
+
stem = stem.slice(0, dimensions.index);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const scaled = SCALED_SUFFIX.test(stem);
|
|
77
|
+
if (scaled) {
|
|
78
|
+
stem = stem.replace(SCALED_SUFFIX, "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { area, scaled, stem };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The identity two derivatives of one image share.
|
|
86
|
+
*
|
|
87
|
+
* Origin and directory are part of it — two sites can both serve `hero.jpg`, and WordPress's
|
|
88
|
+
* `/uploads/<year>/<month>/` layout means the same basename recurs legitimately. The query string
|
|
89
|
+
* is not: a cache-buster is not a different image. A URL that will not parse is its own family,
|
|
90
|
+
* which is the conservative answer (it is kept, not merged with something it may not match).
|
|
91
|
+
*/
|
|
92
|
+
export function familyKey(url: string): string {
|
|
93
|
+
let parsed: URL;
|
|
94
|
+
try {
|
|
95
|
+
parsed = new URL(url);
|
|
96
|
+
} catch {
|
|
97
|
+
return url;
|
|
98
|
+
}
|
|
99
|
+
const { ext } = splitExtension(parsed.pathname);
|
|
100
|
+
const { stem } = parseName(parsed.pathname);
|
|
101
|
+
return `${parsed.origin}${stem}${ext.toLowerCase()}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pick the member of a family to keep.
|
|
106
|
+
*
|
|
107
|
+
* Ranked: an undecorated name beats everything, because it is the upload itself; otherwise the
|
|
108
|
+
* largest `-WxH` wins, and a `-scaled` copy is preferred to a small explicit crop only when nothing
|
|
109
|
+
* else is left. Ties break on the URL string so a crawl is reproducible.
|
|
110
|
+
*
|
|
111
|
+
* @param {readonly string[]} candidates - Absolute URLs, all of one family, non-empty
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function chooseOriginal(candidates: readonly string[]): string {
|
|
115
|
+
const ranked = candidates.map((url) => {
|
|
116
|
+
let pathname: string;
|
|
117
|
+
try {
|
|
118
|
+
({ pathname } = new URL(url));
|
|
119
|
+
} catch {
|
|
120
|
+
pathname = url;
|
|
121
|
+
}
|
|
122
|
+
const { area, scaled } = parseName(pathname);
|
|
123
|
+
// Undecorated first, then scaled-only, then explicit crops largest-first.
|
|
124
|
+
const tier = area === null ? (scaled ? 1 : 0) : 2;
|
|
125
|
+
return { area: area ?? 0, tier, url };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
ranked.sort((a, b) => {
|
|
129
|
+
if (a.tier !== b.tier) {
|
|
130
|
+
return a.tier - b.tier;
|
|
131
|
+
}
|
|
132
|
+
if (a.area !== b.area) {
|
|
133
|
+
return b.area - a.area;
|
|
134
|
+
}
|
|
135
|
+
return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return ranked[0]!.url;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ImageFamilyPlan {
|
|
142
|
+
/** The assets to actually download — one per image family, plus every non-family asset. */
|
|
143
|
+
keep: DiscoveredAsset[];
|
|
144
|
+
/**
|
|
145
|
+
* Dropped URL → the URL kept in its place.
|
|
146
|
+
*
|
|
147
|
+
* The caller replays this over the download's `rewriteMap` so a reference to any derivative still
|
|
148
|
+
* resolves, to the one file that was written.
|
|
149
|
+
*/
|
|
150
|
+
alias: Map<string, string>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Reduce a discovered asset list to one member per image family.
|
|
155
|
+
*
|
|
156
|
+
* Only `<img>`/`<source>` URLs take part. A CSS background, a font and a favicon are each kept as
|
|
157
|
+
* found: a background is authored at one size, and merging two of them on a coincidental name match
|
|
158
|
+
* would silently swap an image the stylesheet asked for.
|
|
159
|
+
*
|
|
160
|
+
* @param {readonly DiscoveredAsset[]} assets
|
|
161
|
+
* @returns {ImageFamilyPlan}
|
|
162
|
+
*/
|
|
163
|
+
export function planImageFamilies(assets: readonly DiscoveredAsset[]): ImageFamilyPlan {
|
|
164
|
+
const families = new Map<string, string[]>();
|
|
165
|
+
for (const asset of assets) {
|
|
166
|
+
if (!FAMILY_SOURCES.has(asset.source)) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const key = familyKey(asset.url);
|
|
170
|
+
families.set(key, [...(families.get(key) ?? []), asset.url]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const winners = new Map<string, string>();
|
|
174
|
+
for (const [key, urls] of families) {
|
|
175
|
+
winners.set(key, chooseOriginal(urls));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const alias = new Map<string, string>();
|
|
179
|
+
const keep: DiscoveredAsset[] = [];
|
|
180
|
+
const taken = new Set<string>();
|
|
181
|
+
|
|
182
|
+
for (const asset of assets) {
|
|
183
|
+
if (!FAMILY_SOURCES.has(asset.source)) {
|
|
184
|
+
keep.push(asset);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const winner = winners.get(familyKey(asset.url))!;
|
|
188
|
+
if (asset.url === winner) {
|
|
189
|
+
if (!taken.has(winner)) {
|
|
190
|
+
taken.add(winner);
|
|
191
|
+
keep.push(asset);
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
alias.set(asset.url, winner);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { alias, keep };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Point every dropped derivative at the file its family actually wrote.
|
|
203
|
+
*
|
|
204
|
+
* The download only ever saw one member per family, so the map it returns has one entry per family.
|
|
205
|
+
* Markup still refers to whichever rung it was authored against, and an unresolved reference is not
|
|
206
|
+
* merely a broken image: `asset-rewrite.ts` leaves a URL it cannot resolve untouched, so the built
|
|
207
|
+
* clone would go on serving that image from the site it was cloned from.
|
|
208
|
+
*
|
|
209
|
+
* @param {Map<string, string>} rewriteMap - Download result, mutated in place
|
|
210
|
+
* @param {ReadonlyMap<string, string>} alias - Dropped URL to kept URL, from
|
|
211
|
+
* {@link planImageFamilies}
|
|
212
|
+
* @returns {number} How many references were reconnected
|
|
213
|
+
*/
|
|
214
|
+
export function applyFamilyAliases(
|
|
215
|
+
rewriteMap: Map<string, string>,
|
|
216
|
+
alias: ReadonlyMap<string, string>,
|
|
217
|
+
): number {
|
|
218
|
+
let reconnected = 0;
|
|
219
|
+
for (const [dropped, kept] of alias) {
|
|
220
|
+
const local = rewriteMap.get(kept);
|
|
221
|
+
if (local !== undefined) {
|
|
222
|
+
rewriteMap.set(dropped, local);
|
|
223
|
+
reconnected += 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return reconnected;
|
|
227
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export { capturePage
|
|
1
|
+
export { capturePage } from "./capture.ts";
|
|
2
|
+
export { launchBrowser, closeBrowser } from "./browser-local.ts";
|
|
3
|
+
export { createLocalIo } from "./io.ts";
|
|
4
|
+
export { runImportPipeline } from "./pipeline.ts";
|
|
2
5
|
export { importSite } from "./run.ts";
|
|
3
6
|
export { convertToJx } from "./to-jx.ts";
|
|
4
7
|
export { emitProject, emitMultiPageProject } from "./emit.ts";
|
|
@@ -33,13 +36,16 @@ export {
|
|
|
33
36
|
serveDirectory,
|
|
34
37
|
routeToUrlPath,
|
|
35
38
|
} from "./verify.ts";
|
|
36
|
-
export type { CaptureResult,
|
|
39
|
+
export type { CaptureResult, ImportBrowser, ImportPage } from "./capture.ts";
|
|
40
|
+
export type { LaunchOptions } from "./browser-local.ts";
|
|
41
|
+
export type { ImportIo } from "./io.ts";
|
|
37
42
|
export type {
|
|
38
|
-
|
|
43
|
+
ImportPipelineOptions,
|
|
44
|
+
ImportPipelineResult,
|
|
39
45
|
ImportProgressEvent,
|
|
40
46
|
ImportPhase,
|
|
41
|
-
|
|
42
|
-
} from "./run.ts";
|
|
47
|
+
} from "./pipeline.ts";
|
|
48
|
+
export type { ImportSiteOptions, ImportSiteResult } from "./run.ts";
|
|
43
49
|
export type { ToJxResult } from "./to-jx.ts";
|
|
44
50
|
export type { EmitOptions, EmitResult, MultiEmitOptions } from "./emit.ts";
|
|
45
51
|
export type { CapturedStyle, StyleCaptureResult } from "./style-capture.ts";
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sink every file an import produces goes through.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline used to call `Bun.write(join(outDir, …))` at six sites, which made "run an import"
|
|
5
|
+
* and "have a writable filesystem" the same requirement. Jx Cloud is a Cloudflare Worker: it has
|
|
6
|
+
* neither `Bun` nor a disk, and commits the emitted project straight to git. So the destination is
|
|
7
|
+
* a parameter now — `createLocalIo` is the disk implementation and the only one that imports
|
|
8
|
+
* `node:fs`, and a Worker supplies its own without the pipeline knowing the difference.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface ImportIo {
|
|
15
|
+
/**
|
|
16
|
+
* Write one file.
|
|
17
|
+
*
|
|
18
|
+
* `relPath` is PROJECT-RELATIVE and forward-slashed — `"pages/blog/post.json"`, never an absolute
|
|
19
|
+
* path and never a backslash. A sink that has no filesystem uses it as a key verbatim; a sink
|
|
20
|
+
* that does joins it onto its own root.
|
|
21
|
+
*/
|
|
22
|
+
write: (relPath: string, data: string | Uint8Array) => Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Create an empty directory, if directories are a thing this sink has.
|
|
25
|
+
*
|
|
26
|
+
* Optional because they usually are not: a git tree, an object store and a zip all record only
|
|
27
|
+
* the files, and an empty directory is unrepresentable in every one of them. The local import
|
|
28
|
+
* still seeds `pages/`, `layouts/`, `components/` and `public/` up front so a host can open the
|
|
29
|
+
* project the moment the run starts, which is what this exists for.
|
|
30
|
+
*/
|
|
31
|
+
mkdir?: (relPath: string) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Split a project-relative path into segments, so a sink's own separator is the one used. */
|
|
35
|
+
function toSegments(relPath: string): string[] {
|
|
36
|
+
return relPath.split("/").filter((segment) => segment !== "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The disk sink: `outDir` plus the relative path, with parent directories created on demand. */
|
|
40
|
+
export function createLocalIo(outDir: string): ImportIo {
|
|
41
|
+
const resolve = (relPath: string): string => join(outDir, ...toSegments(relPath));
|
|
42
|
+
return {
|
|
43
|
+
async write(relPath, data) {
|
|
44
|
+
const path = resolve(relPath);
|
|
45
|
+
await mkdir(dirname(path), { recursive: true });
|
|
46
|
+
await Bun.write(path, data);
|
|
47
|
+
},
|
|
48
|
+
async mkdir(relPath) {
|
|
49
|
+
await mkdir(resolve(relPath), { recursive: true });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/media-extract.ts
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
* so the import can SAY where a width went, not so its styles can be recovered.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type { ImportPage } from "./capture.ts";
|
|
16
16
|
import type { CapturedStyle } from "./style-capture.ts";
|
|
17
17
|
import { captureStylesAtWidth } from "./style-capture.ts";
|
|
18
18
|
import { computeMediaDelta } from "./style-diff.ts";
|
|
19
|
-
import { analyzeMediaQueries, planBreakpoints } from "./breakpoint-plan.ts";
|
|
19
|
+
import { analyzeMediaQueries, parseWidthQuery, planBreakpoints } from "./breakpoint-plan.ts";
|
|
20
20
|
import type { Breakpoint, BreakpointPolicy } from "./breakpoint-plan.ts";
|
|
21
21
|
import type { DiffedStyle } from "./style-diff.ts";
|
|
22
22
|
|
|
@@ -53,10 +53,70 @@ export interface ExtractMediaOptions {
|
|
|
53
53
|
plan?: readonly Breakpoint[] | undefined;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* How far inside its own band the corroborating sample sits. Wide enough that a fluid element
|
|
58
|
+
* measures differently, narrow enough to stay clear of the neighbouring breakpoint.
|
|
59
|
+
*/
|
|
60
|
+
const SECOND_SAMPLE_OFFSET = 40;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A second viewport width INSIDE the same media band.
|
|
64
|
+
*
|
|
65
|
+
* The direction is the whole point: for `(max-width: N)` the band runs downward, so the second
|
|
66
|
+
* sample is narrower; for `(min-width: N)` it runs upward and the sample must be wider, or it
|
|
67
|
+
* leaves the band and measures the rules this breakpoint exists to distinguish itself from.
|
|
68
|
+
*/
|
|
69
|
+
export function secondSampleWidth(bp: Breakpoint): number {
|
|
70
|
+
const parsed = parseWidthQuery(bp.query);
|
|
71
|
+
const direction = parsed?.type === "min" ? 1 : -1;
|
|
72
|
+
return Math.max(1, bp.testWidth + direction * SECOND_SAMPLE_OFFSET);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Keep only the declarations two samples of the same band AGREE on.
|
|
77
|
+
*
|
|
78
|
+
* `getComputedStyle` returns used values, so `width` and `height` come back as the pixels the
|
|
79
|
+
* element happens to occupy - never the authored `100%` or `auto`. Diffing one sample against the
|
|
80
|
+
* base therefore reported a "change" for every fluid element at every breakpoint: two thirds of all
|
|
81
|
+
* responsive declarations in a real import were the viewport's own width written back as the
|
|
82
|
+
* element's, which does not merely waste space but PINS the layout and defeats the reflow the
|
|
83
|
+
* breakpoint existed for.
|
|
84
|
+
*
|
|
85
|
+
* Measuring twice inside the band separates the two cases without knowing which properties are
|
|
86
|
+
* geometry. An authored value is the same at both widths and survives; a resolved one moves with
|
|
87
|
+
* the viewport and is dropped. A property whose value legitimately differs across the band (a
|
|
88
|
+
* percentage-positioned element) is dropped too - that is the intended trade, since nothing in a
|
|
89
|
+
* computed-style capture can tell it from noise.
|
|
90
|
+
*/
|
|
91
|
+
export function agreedDeltas(
|
|
92
|
+
primary: readonly DiffedStyle[],
|
|
93
|
+
corroborating: readonly DiffedStyle[],
|
|
94
|
+
): DiffedStyle[] {
|
|
95
|
+
const second = new Map(corroborating.map((d) => [d.path.join(","), d.style]));
|
|
96
|
+
const agreed: DiffedStyle[] = [];
|
|
97
|
+
|
|
98
|
+
for (const delta of primary) {
|
|
99
|
+
const other = second.get(delta.path.join(","));
|
|
100
|
+
if (!other) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const style: Record<string, string | number> = {};
|
|
104
|
+
for (const [prop, value] of Object.entries(delta.style)) {
|
|
105
|
+
if (other[prop] === value) {
|
|
106
|
+
style[prop] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Object.keys(style).length > 0) {
|
|
110
|
+
agreed.push({ path: delta.path, style });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return agreed;
|
|
114
|
+
}
|
|
115
|
+
|
|
56
116
|
/**
|
|
57
117
|
* Extract `$media` style deltas by re-capturing at each KEPT breakpoint width.
|
|
58
118
|
*
|
|
59
|
-
* @param {
|
|
119
|
+
* @param {ImportPage} page - Browser page (must still have the target page loaded)
|
|
60
120
|
* @param {CapturedStyle[]} baseElements - Style capture from the base viewport
|
|
61
121
|
* @param {Record<string, Record<string, string>>} uaDefaults - UA-default baselines per tagName
|
|
62
122
|
* @param {readonly string[]} mediaQueries - `@media` queries discovered in the page's stylesheets
|
|
@@ -64,7 +124,7 @@ export interface ExtractMediaOptions {
|
|
|
64
124
|
* @returns {Promise<MediaExtractionResult>}
|
|
65
125
|
*/
|
|
66
126
|
export async function extractMedia(
|
|
67
|
-
page:
|
|
127
|
+
page: ImportPage,
|
|
68
128
|
baseElements: CapturedStyle[],
|
|
69
129
|
uaDefaults: Record<string, Record<string, string>>,
|
|
70
130
|
mediaQueries: readonly string[],
|
|
@@ -81,8 +141,17 @@ export async function extractMedia(
|
|
|
81
141
|
const allDeltas: Record<string, DiffedStyle[]> = {};
|
|
82
142
|
|
|
83
143
|
for (const bp of keep) {
|
|
84
|
-
const
|
|
85
|
-
|
|
144
|
+
const primary = computeMediaDelta(
|
|
145
|
+
baseElements,
|
|
146
|
+
await captureStylesAtWidth(page, bp.testWidth),
|
|
147
|
+
uaDefaults,
|
|
148
|
+
);
|
|
149
|
+
const corroborating = computeMediaDelta(
|
|
150
|
+
baseElements,
|
|
151
|
+
await captureStylesAtWidth(page, secondSampleWidth(bp)),
|
|
152
|
+
uaDefaults,
|
|
153
|
+
);
|
|
154
|
+
const deltas = agreedDeltas(primary, corroborating);
|
|
86
155
|
|
|
87
156
|
if (deltas.length > 0) {
|
|
88
157
|
projectBreakpoints[bp.name] = bp.query;
|