@jxsuite/import 0.40.1 → 0.40.3

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/src/emit.ts CHANGED
@@ -166,10 +166,16 @@ export async function emitMultiPageProject({
166
166
  tagName: comp.tagName,
167
167
  };
168
168
 
169
- const state: Record<string, string> = {};
170
- extractStateDefaults(comp.template, state);
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 };
171
177
  if (Object.keys(state).length > 0) {
172
- compDoc.state = state;
178
+ compDoc.state = state as Record<string, string>;
173
179
  }
174
180
 
175
181
  componentFiles.set(fileName, compDoc);
@@ -298,16 +304,29 @@ export async function emitMultiPageProject({
298
304
  return { classesStripped, files, projectJson: projectText };
299
305
  }
300
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;
318
+ }
319
+
301
320
  function extractStateDefaults(node: JxElement | string, out: Record<string, string>) {
302
321
  if (typeof node === "string") {
303
- const key = node.match(/^\$\{state\.([^}]+)\}$/)?.[1];
322
+ const key = stateKey(node);
304
323
  if (key && !(key in out)) {
305
324
  out[key] = "";
306
325
  }
307
326
  return;
308
327
  }
309
328
  if (typeof node.textContent === "string") {
310
- const key = node.textContent.match(/^\$\{state\.([^}]+)\}$/)?.[1];
329
+ const key = stateKey(node.textContent);
311
330
  if (key && !(key in out)) {
312
331
  out[key] = "";
313
332
  }
@@ -315,7 +334,7 @@ function extractStateDefaults(node: JxElement | string, out: Record<string, stri
315
334
  if (node.attributes) {
316
335
  for (const v of Object.values(node.attributes)) {
317
336
  if (typeof v === "string") {
318
- const key = v.match(/^\$\{state\.([^}]+)\}$/)?.[1];
337
+ const key = stateKey(v);
319
338
  if (key && !(key in out)) {
320
339
  out[key] = "";
321
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
+ }
@@ -16,7 +16,7 @@ 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,6 +53,66 @@ 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
  *
@@ -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 bpElements = await captureStylesAtWidth(page, bp.testWidth);
85
- const deltas = computeMediaDelta(baseElements, bpElements, uaDefaults);
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;
package/src/pipeline.ts CHANGED
@@ -31,10 +31,14 @@ import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
31
31
  import { applyStylesToTree } from "./apply-styles.ts";
32
32
  import { collectAssets } from "./asset-collect.ts";
33
33
  import { downloadAssets } from "./asset-download.ts";
34
+ import { applyFamilyAliases, planImageFamilies } from "./image-family.ts";
34
35
  import { rewriteAssetUrls } from "./asset-rewrite.ts";
35
36
  import { applyTokens } from "./css-tokens.ts";
36
37
  import { crawlSite } from "./crawl.ts";
37
38
  import { detectLayout } from "./layout-detect.ts";
39
+ import { applyAccordions } from "./apply-accordions.ts";
40
+ import { applyPopovers } from "./apply-popovers.ts";
41
+ import { applyDisclosures } from "./apply-disclosures.ts";
38
42
  import type { ImportIo } from "./io.ts";
39
43
  import type { JxElement } from "@jxsuite/schema/types";
40
44
  import type { ComponentizeResult } from "./componentize.ts";
@@ -99,6 +103,7 @@ export type ImportPhase =
99
103
  | "crawl"
100
104
  | "styles"
101
105
  | "assets"
106
+ | "convert"
102
107
  | "layout"
103
108
  | "components"
104
109
  | "ai"
@@ -322,7 +327,7 @@ export async function runImportPipeline(
322
327
  if (bpCount > 0) {
323
328
  progress("styles", `${bpCount} breakpoints with style changes`);
324
329
  ({ breakpoints } = media);
325
- applyStylesToTree(jx.document, diffed, media.deltas);
330
+ applyStylesToTree(jx.document, diffed, media.deltas, media.breakpoints);
326
331
  } else {
327
332
  progress("styles", "No responsive breakpoints with style changes");
328
333
  applyStylesToTree(jx.document, diffed);
@@ -368,7 +373,12 @@ export async function runImportPipeline(
368
373
 
369
374
  if (collected.assets.length > 0) {
370
375
  progress("assets", "Downloading assets...");
371
- const downloaded = await downloadAssets(collected.assets, io, url);
376
+ /* One member per responsive family reaches the network; every dropped derivative is
377
+ aliased onto the file that WAS written, so a reference to any rung of the ladder still
378
+ resolves. The compiler regenerates the sizes from the original it now owns. */
379
+ const families = planImageFamilies(collected.assets);
380
+ const downloaded = await downloadAssets(families.keep, io, url);
381
+ applyFamilyAliases(downloaded.rewriteMap, families.alias);
372
382
  progress(
373
383
  "assets",
374
384
  `Downloaded ${downloaded.rewriteMap.size} assets (${formatBytes(downloaded.totalBytes)})`,
@@ -408,6 +418,31 @@ export async function runImportPipeline(
408
418
  await capture.page.close();
409
419
 
410
420
  throwIfAborted(signal);
421
+ /* Widgets become native markup BEFORE layout detection, which replaces the pages it extracts a
422
+ layout from - a pass after it would see the site's widgets in neither half. */
423
+ const singleConverted = applyAccordions(jx.document);
424
+ const singleDisclosures = applyDisclosures(jx.document);
425
+ if (singleDisclosures.converted > 0) {
426
+ progress(
427
+ "convert",
428
+ `Converted ${singleDisclosures.converted} disclosure${singleDisclosures.converted === 1 ? "" : "s"} to native <details>`,
429
+ );
430
+ }
431
+ const singleOverlays = applyPopovers(jx.document);
432
+ if (singleOverlays.converted > 0) {
433
+ progress(
434
+ "convert",
435
+ `Converted ${singleOverlays.converted} dropdown${singleOverlays.converted === 1 ? "" : "s"} to popovers`,
436
+ );
437
+ }
438
+ if (singleConverted.converted > 0) {
439
+ progress(
440
+ "convert",
441
+ `Converted ${singleConverted.converted} accordion${singleConverted.converted === 1 ? "" : "s"} ` +
442
+ `(${singleConverted.rows} rows) to native <details>`,
443
+ );
444
+ }
445
+
411
446
  const pageMap = new Map([["pages/index.json", jx.document]]);
412
447
  const precomputedComponents = await maybeAiComponentize(
413
448
  pageMap,
@@ -484,6 +519,39 @@ export async function runImportPipeline(
484
519
  pageMap.set(page.route, page.jx.document);
485
520
  }
486
521
 
522
+ /* Widgets become native markup BEFORE layout detection, which replaces the pages it extracts a
523
+ layout from - a pass after it would see the site's widgets in neither half. */
524
+ let convertedWidgets = 0;
525
+ let convertedRows = 0;
526
+ let convertedOverlays = 0;
527
+ let convertedDisclosures = 0;
528
+ for (const document of pageMap.values()) {
529
+ const converted = applyAccordions(document);
530
+ convertedWidgets += converted.converted;
531
+ convertedRows += converted.rows;
532
+ convertedDisclosures += applyDisclosures(document).converted;
533
+ convertedOverlays += applyPopovers(document).converted;
534
+ }
535
+ if (convertedDisclosures > 0) {
536
+ progress(
537
+ "convert",
538
+ `Converted ${convertedDisclosures} disclosure${convertedDisclosures === 1 ? "" : "s"} to native <details>`,
539
+ );
540
+ }
541
+ if (convertedOverlays > 0) {
542
+ progress(
543
+ "convert",
544
+ `Converted ${convertedOverlays} dropdown${convertedOverlays === 1 ? "" : "s"} to popovers`,
545
+ );
546
+ }
547
+ if (convertedWidgets > 0) {
548
+ progress(
549
+ "convert",
550
+ `Converted ${convertedWidgets} accordion${convertedWidgets === 1 ? "" : "s"} ` +
551
+ `(${convertedRows} rows) to native <details>`,
552
+ );
553
+ }
554
+
487
555
  // Layout detection
488
556
  let layout: JxElement | undefined;
489
557
  if (result.pages.length >= 2) {