@jxsuite/import 0.39.8 → 0.40.1

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.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * The import pipeline: capture → styles → assets → layout → componentize → emit.
3
+ *
4
+ * Everything here is portable. It takes the browser it drives and the sink it writes to as
5
+ * parameters, and it touches nothing else — no `node:fs`, no `Bun.*`, no `process.env`, and no
6
+ * VALUE import of `puppeteer-core` anywhere in its graph. That last one is the reason the module
7
+ * exists: a Worker bundler follows value imports transitively, `puppeteer-core` cannot load in
8
+ * workerd, and the phases had it in reach through `capture.ts`. Type-only imports are erased, so
9
+ * they are fine.
10
+ *
11
+ * Two things are deliberately NOT here. Verification builds the project with `@jxsuite/compiler`
12
+ * and serves it over `Bun.serve` to screenshot it — it executes the project's own JavaScript, which
13
+ * a hosted platform never does — so it stays a local phase, in `run.ts`. And the empty-directory
14
+ * guard and the `project.json` seed are filesystem questions, which is the same answer.
15
+ */
16
+
17
+ import { capturePage } from "./capture.ts";
18
+ import type { ImportBrowser, ImportPage } from "./capture.ts";
19
+ import { convertToJx } from "./to-jx.ts";
20
+ import { emitMultiPageProject } from "./emit.ts";
21
+ import { captureStyles } from "./style-capture.ts";
22
+ import { extractMedia } from "./media-extract.ts";
23
+ import {
24
+ DEFAULT_BREAKPOINT_POLICY,
25
+ analyzeMediaQueries,
26
+ planBreakpoints,
27
+ skippedWidthQueries,
28
+ } from "./breakpoint-plan.ts";
29
+ import type { BreakpointPolicy } from "./breakpoint-plan.ts";
30
+ import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
31
+ import { applyStylesToTree } from "./apply-styles.ts";
32
+ import { collectAssets } from "./asset-collect.ts";
33
+ import { downloadAssets } from "./asset-download.ts";
34
+ import { rewriteAssetUrls } from "./asset-rewrite.ts";
35
+ import { applyTokens } from "./css-tokens.ts";
36
+ import { crawlSite } from "./crawl.ts";
37
+ import { detectLayout } from "./layout-detect.ts";
38
+ import type { ImportIo } from "./io.ts";
39
+ import type { JxElement } from "@jxsuite/schema/types";
40
+ import type { ComponentizeResult } from "./componentize.ts";
41
+
42
+ export type { ImportIo } from "./io.ts";
43
+ export type { ImportBrowser, ImportPage } from "./capture.ts";
44
+
45
+ export interface ImportPipelineOptions {
46
+ /** The page to import; must be http(s). */
47
+ url: string;
48
+ /** The browser to drive. Anything providing `newPage` — a local Chrome, a remote session. */
49
+ browser: ImportBrowser;
50
+ /** Where every emitted file and downloaded asset goes. */
51
+ io: ImportIo;
52
+ /** Max crawl depth; 0 = single-page pipeline (default 2). */
53
+ maxDepth?: number;
54
+ /** Max pages to capture in crawl mode (default 25). */
55
+ maxPages?: number;
56
+ /** Skip styles/assets for pages above this node count (default 5000). */
57
+ maxNodesPerPage?: number;
58
+ /**
59
+ * Which of the site's declared breakpoints the project keeps (default: three, evenly spaced).
60
+ *
61
+ * A real site declares as many breakpoints as it has accumulated frameworks; nine `$media`
62
+ * entries is nine canvas sizes in Studio and nine columns in every style editor, and nobody
63
+ * authors against that. See `breakpoint-plan.ts`.
64
+ */
65
+ breakpoints?: BreakpointPolicy;
66
+ /** Capture computed styles (default true). */
67
+ styles?: boolean;
68
+ /** Download and rewrite assets (default true). */
69
+ assets?: boolean;
70
+ /** Scroll to the bottom before capture to trigger lazy content (default true). */
71
+ scroll?: boolean;
72
+ /** Respect robots.txt in crawl mode (default true). */
73
+ respectRobots?: boolean;
74
+ /** Heuristic component extraction; false to skip (default on with standard thresholds). */
75
+ componentize?: false | { minInstances?: number; minDepth?: number };
76
+ /**
77
+ * LLM refinement of component/prop names; false/undefined to skip.
78
+ *
79
+ * `model` is required here for the reason `AiComponentizeOptions` gives: a default openai model
80
+ * id is a 404 on any other provider, and the caller is the one that knows which it is talking
81
+ * to.
82
+ */
83
+ ai?: false | { apiKey: string; baseUrl?: string | undefined; model: string };
84
+ /**
85
+ * Screenshot each captured page and return the bytes; false to skip (default false).
86
+ *
87
+ * The pipeline never diffs them — that is `verify.ts`, which is local-only. It captures them
88
+ * because they can only be taken while the ORIGINAL page is still open, which is inside here.
89
+ */
90
+ referenceScreenshots?: false | { fullPage?: boolean };
91
+ /** Aborts between phases (and between crawled pages). */
92
+ signal?: AbortSignal;
93
+ }
94
+
95
+ export type ImportPhase =
96
+ | "seed"
97
+ | "launch"
98
+ | "capture"
99
+ | "crawl"
100
+ | "styles"
101
+ | "assets"
102
+ | "layout"
103
+ | "components"
104
+ | "ai"
105
+ | "emit"
106
+ | "verify"
107
+ | "done";
108
+
109
+ export interface ImportProgressEvent {
110
+ phase: ImportPhase;
111
+ message: string;
112
+ current?: number;
113
+ total?: number;
114
+ /**
115
+ * The project root, on the `seed` event alone.
116
+ *
117
+ * The destination exists and holds a valid `project.json` from that moment, which is what lets a
118
+ * host OPEN it and watch the rest of the run land in its own file tree — rather than staring at a
119
+ * welcome screen for the several minutes a crawl takes. Only a caller that HAS a root emits it;
120
+ * the pipeline does not know whether it is writing to one.
121
+ */
122
+ root?: string;
123
+ }
124
+
125
+ export interface ImportPipelineResult {
126
+ pages: { route: string; title: string; nodeCount: number }[];
127
+ /** Project-relative paths of every file the emit phase wrote. */
128
+ files: string[];
129
+ /** The emitted `project.json` text, so a caller with no filesystem need not read it back. */
130
+ projectJson: string;
131
+ /**
132
+ * Reference screenshots by route, when `referenceScreenshots` asked for them.
133
+ *
134
+ * Empty otherwise, and possibly empty even then — a crawl that captured nothing renderable has
135
+ * nothing to verify against, which is a real outcome rather than an error.
136
+ */
137
+ references: Map<string, { sourceUrl: string; screenshot: Uint8Array }>;
138
+ warnings: string[];
139
+ }
140
+
141
+ /**
142
+ * The viewport the base capture runs at, and therefore the project's base width.
143
+ *
144
+ * It is `capture.ts`'s own default; naming it here is what lets the emitted `$media` carry a `"--"`
145
+ * base entry that agrees with the styles actually sampled. Without one, Studio's canvas falls back
146
+ * to 320px — narrower than every breakpoint an import discovers.
147
+ */
148
+ const CAPTURE_WIDTH = 1440;
149
+
150
+ /** The height the reference screenshot is framed at before a full-page capture expands it. */
151
+ const CAPTURE_HEIGHT = 900;
152
+
153
+ function throwIfAborted(signal?: AbortSignal): void {
154
+ if (signal?.aborted) {
155
+ throw new Error("Import aborted");
156
+ }
157
+ }
158
+
159
+ function formatBytes(bytes: number): string {
160
+ if (bytes < 1024) {
161
+ return `${bytes} B`;
162
+ }
163
+ if (bytes < 1024 * 1024) {
164
+ return `${(bytes / 1024).toFixed(1)} KB`;
165
+ }
166
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
167
+ }
168
+
169
+ /**
170
+ * Frame and shoot the original page.
171
+ *
172
+ * Inlined rather than reached for in `verify.ts`, which is where the equivalent used to live: that
173
+ * module builds and serves the project, so importing it for two calls would have put a compiler and
174
+ * `Bun.serve` in the pipeline's graph.
175
+ */
176
+ async function captureReference(page: ImportPage, fullPage: boolean): Promise<Uint8Array> {
177
+ await page.setViewport({ width: CAPTURE_WIDTH, height: CAPTURE_HEIGHT });
178
+ return page.screenshot({ fullPage, type: "png" });
179
+ }
180
+
181
+ /**
182
+ * Run the heuristic componentize pass and refine it with the LLM. Returns undefined when the AI
183
+ * pass is off, componentization is disabled, or the heuristic found nothing (the emit step then
184
+ * runs its own heuristic pass as usual).
185
+ */
186
+ async function maybeAiComponentize(
187
+ pageMap: Map<string, JxElement>,
188
+ componentizeOptions: false | { minInstances?: number; minDepth?: number },
189
+ ai: ImportPipelineOptions["ai"],
190
+ progress: (phase: ImportPhase, message: string) => void,
191
+ ): Promise<ComponentizeResult | undefined> {
192
+ if (!ai || componentizeOptions === false) {
193
+ return undefined;
194
+ }
195
+ const { componentize } = await import("./componentize.ts");
196
+ const { aiComponentize } = await import("./ai-componentize.ts");
197
+ progress("components", "Running heuristic componentization...");
198
+ const heuristic = componentize(pageMap, componentizeOptions);
199
+ if (heuristic.components.size === 0) {
200
+ return undefined;
201
+ }
202
+ progress("ai", `Found ${heuristic.components.size} component(s), refining with AI...`);
203
+ const refined = await aiComponentize(
204
+ heuristic,
205
+ { apiKey: ai.apiKey, baseUrl: ai.baseUrl, model: ai.model },
206
+ (msg) => progress("ai", msg),
207
+ );
208
+ progress("ai", `AI refined ${refined.components.size} component(s)`);
209
+ return refined;
210
+ }
211
+
212
+ /**
213
+ * Import a live website into a Jx project, writing every file through `io`.
214
+ *
215
+ * @param {ImportPipelineOptions} options
216
+ * @param {(e: ImportProgressEvent) => void} [onProgress]
217
+ */
218
+ export async function runImportPipeline(
219
+ options: ImportPipelineOptions,
220
+ onProgress?: (e: ImportProgressEvent) => void,
221
+ ): Promise<ImportPipelineResult> {
222
+ const {
223
+ url,
224
+ browser,
225
+ io,
226
+ maxDepth = 2,
227
+ maxPages = 25,
228
+ maxNodesPerPage = 5000,
229
+ styles = true,
230
+ assets = true,
231
+ scroll = true,
232
+ respectRobots = true,
233
+ componentize: componentizeOptions = {},
234
+ ai = false,
235
+ referenceScreenshots = false,
236
+ signal,
237
+ breakpoints: breakpointPolicy = DEFAULT_BREAKPOINT_POLICY,
238
+ } = options;
239
+
240
+ const progress = (
241
+ phase: ImportPhase,
242
+ message: string,
243
+ counts?: { current?: number; total?: number },
244
+ ) => {
245
+ onProgress?.({ phase, message, ...counts });
246
+ };
247
+ const warnings: string[] = [];
248
+ const warn = (phase: ImportPhase, message: string) => {
249
+ warnings.push(message);
250
+ progress(phase, `⚠ ${message}`);
251
+ };
252
+ const referenceFullPage =
253
+ referenceScreenshots === false ? true : (referenceScreenshots.fullPage ?? true);
254
+
255
+ return maxDepth === 0 ? runSinglePage() : runCrawl();
256
+
257
+ // ─── Single-page mode: Phase 0-2 pipeline, no crawl overhead ────────────────
258
+ async function runSinglePage(): Promise<ImportPipelineResult> {
259
+ throwIfAborted(signal);
260
+ progress("capture", "Capturing page...");
261
+ const capture = await capturePage(url, browser, { scrollToBottom: scroll });
262
+ progress("capture", `Captured: "${capture.title}" (${capture.links.length} links found)`);
263
+
264
+ progress("capture", "Converting to Jx...");
265
+ const jx = convertToJx(capture.bodyHtml);
266
+ progress(
267
+ "capture",
268
+ `Converted: ${jx.nodeCount} nodes, ${jx.collectedStyles.length} inline stylesheets collected`,
269
+ );
270
+ if (jx.nodeCount > maxNodesPerPage) {
271
+ warn("capture", `Large page (${jx.nodeCount} nodes). Studio may be slow.`);
272
+ }
273
+
274
+ let breakpoints: Record<string, string> | undefined;
275
+ let styleTokens: Record<string, string> | undefined;
276
+
277
+ if (styles) {
278
+ throwIfAborted(signal);
279
+ progress("styles", "Capturing computed styles...");
280
+ const styleResult = await captureStyles(capture.page);
281
+ progress(
282
+ "styles",
283
+ `Captured styles for ${styleResult.elements.length} elements, ${Object.keys(styleResult.uaDefaults).length} tag baselines`,
284
+ );
285
+
286
+ progress("styles", "Diffing against UA defaults...");
287
+ const diffed = diffAllStyles(styleResult.elements, styleResult.uaDefaults);
288
+ progress("styles", `${diffed.length} elements with non-default styles`);
289
+
290
+ // Replace resolved values with var(--name) references
291
+ const propCount = Object.keys(styleResult.customProperties).length;
292
+ if (propCount > 0) {
293
+ const tokenResult = applyTokens(diffed, styleResult.customProperties);
294
+ if (tokenResult.replacements > 0) {
295
+ styleTokens = tokenResult.tokens;
296
+ progress(
297
+ "styles",
298
+ `Extracted ${Object.keys(tokenResult.tokens).length} design tokens (${tokenResult.replacements} replacements)`,
299
+ );
300
+ } else {
301
+ progress(
302
+ "styles",
303
+ `${propCount} custom properties found, but none matched computed values`,
304
+ );
305
+ }
306
+ }
307
+
308
+ if (styleResult.mediaQueries.length > 0) {
309
+ progress(
310
+ "styles",
311
+ `Extracting @media breakpoints (${styleResult.mediaQueries.length} queries found)...`,
312
+ );
313
+ reportBreakpointPlan(styleResult.mediaQueries);
314
+ const media = await extractMedia(
315
+ capture.page,
316
+ styleResult.elements,
317
+ styleResult.uaDefaults,
318
+ styleResult.mediaQueries,
319
+ { policy: breakpointPolicy },
320
+ );
321
+ const bpCount = Object.keys(media.breakpoints).length;
322
+ if (bpCount > 0) {
323
+ progress("styles", `${bpCount} breakpoints with style changes`);
324
+ ({ breakpoints } = media);
325
+ applyStylesToTree(jx.document, diffed, media.deltas);
326
+ } else {
327
+ progress("styles", "No responsive breakpoints with style changes");
328
+ applyStylesToTree(jx.document, diffed);
329
+ }
330
+ } else {
331
+ progress("styles", "No @media queries found");
332
+ applyStylesToTree(jx.document, diffed);
333
+ }
334
+
335
+ // Apply <html>/<body> computed styles to the root wrapper
336
+ if (Object.keys(styleResult.documentStyles).length > 0) {
337
+ if (!jx.document.style) {
338
+ jx.document.style = {};
339
+ }
340
+ for (const [prop, val] of Object.entries(styleResult.documentStyles)) {
341
+ const camel = kebabToCamel(prop);
342
+ jx.document.style[camel] = val;
343
+ }
344
+ progress(
345
+ "styles",
346
+ `Applied ${Object.keys(styleResult.documentStyles).length} document-level styles to root`,
347
+ );
348
+ }
349
+ }
350
+
351
+ let fontFaceRules: string[] | undefined;
352
+ let fontRewriteMap: Map<string, string> | undefined;
353
+
354
+ if (assets) {
355
+ throwIfAborted(signal);
356
+ progress("assets", "Collecting asset URLs...");
357
+ const collected = await collectAssets(capture.page);
358
+ progress(
359
+ "assets",
360
+ `Found ${collected.assets.length} assets (${collected.inlineSvgCount} inline SVGs kept, ${collected.stylesheets.length} stylesheets retained)`,
361
+ );
362
+
363
+ const allFontRules = collected.stylesheets.flatMap((s) => s.fontFaceRules);
364
+ if (allFontRules.length > 0) {
365
+ fontFaceRules = allFontRules;
366
+ progress("assets", `Found ${allFontRules.length} @font-face rules`);
367
+ }
368
+
369
+ if (collected.assets.length > 0) {
370
+ progress("assets", "Downloading assets...");
371
+ const downloaded = await downloadAssets(collected.assets, io, url);
372
+ progress(
373
+ "assets",
374
+ `Downloaded ${downloaded.rewriteMap.size} assets (${formatBytes(downloaded.totalBytes)})`,
375
+ );
376
+ if (downloaded.failed.length > 0) {
377
+ warn("assets", `${downloaded.failed.length} assets failed to download`);
378
+ }
379
+ if (downloaded.skipped.length > 0) {
380
+ progress("assets", `Skipped ${downloaded.skipped.length} tracking/analytics URLs`);
381
+ }
382
+
383
+ if (fontFaceRules) {
384
+ fontRewriteMap = new Map<string, string>();
385
+ for (const [originalUrl, localPath] of downloaded.rewriteMap) {
386
+ if (localPath.includes("/fonts/") || /\.(woff2?|ttf|otf|eot)$/i.test(localPath)) {
387
+ fontRewriteMap.set(originalUrl, localPath);
388
+ }
389
+ }
390
+ }
391
+
392
+ progress("assets", "Rewriting asset URLs...");
393
+ const rewrites = rewriteAssetUrls(jx.document, downloaded.rewriteMap, url);
394
+ progress("assets", `Rewrote ${rewrites} URL references`);
395
+ }
396
+ }
397
+
398
+ // The reference has to be taken before the page closes — that is the whole reason it is here.
399
+ const references = new Map<string, { sourceUrl: string; screenshot: Uint8Array }>();
400
+ if (referenceScreenshots !== false) {
401
+ progress("verify", "Capturing reference screenshot...");
402
+ references.set("pages/index.json", {
403
+ sourceUrl: url,
404
+ screenshot: await captureReference(capture.page, referenceFullPage),
405
+ });
406
+ }
407
+
408
+ await capture.page.close();
409
+
410
+ throwIfAborted(signal);
411
+ const pageMap = new Map([["pages/index.json", jx.document]]);
412
+ const precomputedComponents = await maybeAiComponentize(
413
+ pageMap,
414
+ componentizeOptions,
415
+ ai,
416
+ progress,
417
+ );
418
+
419
+ throwIfAborted(signal);
420
+ progress("emit", "Writing project...");
421
+ const emitted = await emitMultiPageProject({
422
+ io,
423
+ title: capture.title,
424
+ pages: pageMap,
425
+ sourceUrl: url,
426
+ breakpoints,
427
+ baseWidth: CAPTURE_WIDTH,
428
+ componentizeOptions: precomputedComponents ? false : componentizeOptions,
429
+ precomputedComponents,
430
+ fontFaceRules,
431
+ fontRewriteMap,
432
+ styleTokens,
433
+ });
434
+ reportEmit(emitted.files.length, emitted.classesStripped);
435
+
436
+ return {
437
+ pages: [{ route: "pages/index.json", title: capture.title, nodeCount: jx.nodeCount }],
438
+ files: emitted.files,
439
+ projectJson: emitted.projectJson,
440
+ references,
441
+ warnings,
442
+ };
443
+ }
444
+
445
+ // ─── Multi-page crawl mode (Phase 3) ────────────────────────────────────────
446
+ async function runCrawl(): Promise<ImportPipelineResult> {
447
+ throwIfAborted(signal);
448
+ const result = await crawlSite({
449
+ url,
450
+ browser,
451
+ io,
452
+ maxDepth,
453
+ maxPages,
454
+ maxNodesPerPage,
455
+ skipStyles: !styles,
456
+ skipAssets: !assets,
457
+ respectRobots,
458
+ noScroll: !scroll,
459
+ captureScreenshots: referenceScreenshots !== false,
460
+ // The reference and the render must be framed the same way, or the diff pads one of them.
461
+ fullPageScreenshots: referenceFullPage,
462
+ breakpoints: breakpointPolicy,
463
+ onProgress: (msg) => progress("crawl", msg.trim()),
464
+ ...(signal === undefined ? {} : { signal }),
465
+ });
466
+
467
+ progress(
468
+ "crawl",
469
+ `Crawled ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`,
470
+ { current: result.pages.length, total: maxPages },
471
+ );
472
+ if (result.skippedByRobots.length > 0) {
473
+ progress("crawl", `Skipped ${result.skippedByRobots.length} URLs (robots.txt)`);
474
+ }
475
+ if (result.skippedByNodeCap.length > 0) {
476
+ warn(
477
+ "crawl",
478
+ `${result.skippedByNodeCap.length} pages exceeded node cap (styles/assets skipped)`,
479
+ );
480
+ }
481
+
482
+ const pageMap = new Map<string, JxElement>();
483
+ for (const page of result.pages) {
484
+ pageMap.set(page.route, page.jx.document);
485
+ }
486
+
487
+ // Layout detection
488
+ let layout: JxElement | undefined;
489
+ if (result.pages.length >= 2) {
490
+ throwIfAborted(signal);
491
+ progress("layout", "Detecting shared layout (header/footer)...");
492
+ const layoutResult = detectLayout(pageMap);
493
+ if (layoutResult) {
494
+ ({ layout } = layoutResult);
495
+ const sharedCount = (Array.isArray(layout.children) ? layout.children.length : 0) - 1; // Minus the slot
496
+ progress("layout", `Found shared layout with ${sharedCount} shared elements`);
497
+ for (const [route, stripped] of layoutResult.strippedPages) {
498
+ pageMap.set(route, stripped);
499
+ }
500
+ } else {
501
+ progress("layout", "No shared layout detected");
502
+ }
503
+ }
504
+
505
+ throwIfAborted(signal);
506
+ const precomputedComponents = await maybeAiComponentize(
507
+ pageMap,
508
+ componentizeOptions,
509
+ ai,
510
+ progress,
511
+ );
512
+
513
+ throwIfAborted(signal);
514
+ progress("emit", "Writing project...");
515
+ const title = result.pages[0]?.title || new URL(url).hostname;
516
+ const emitted = await emitMultiPageProject({
517
+ io,
518
+ title,
519
+ sourceUrl: url,
520
+ pages: pageMap,
521
+ layout,
522
+ breakpoints: result.breakpoints,
523
+ baseWidth: CAPTURE_WIDTH,
524
+ componentizeOptions: precomputedComponents ? false : componentizeOptions,
525
+ precomputedComponents,
526
+ fontFaceRules: result.fontFaceRules.length > 0 ? result.fontFaceRules : undefined,
527
+ fontRewriteMap: result.fontRewriteMap.size > 0 ? result.fontRewriteMap : undefined,
528
+ styleTokens: result.styleTokens,
529
+ });
530
+ reportEmit(emitted.files.length, emitted.classesStripped);
531
+
532
+ const references = new Map<string, { sourceUrl: string; screenshot: Uint8Array }>();
533
+ for (const page of result.pages) {
534
+ if (page.screenshot) {
535
+ references.set(page.route, { sourceUrl: page.url, screenshot: page.screenshot });
536
+ }
537
+ }
538
+
539
+ return {
540
+ pages: result.pages.map((p) => ({
541
+ route: p.route,
542
+ title: p.title,
543
+ nodeCount: p.jx.nodeCount,
544
+ })),
545
+ files: emitted.files,
546
+ projectJson: emitted.projectJson,
547
+ references,
548
+ warnings,
549
+ };
550
+ }
551
+
552
+ /** One emit line, naming what the strip pass removed — silence would read as "nothing to do". */
553
+ function reportEmit(fileCount: number, classesStripped: number): void {
554
+ const classes =
555
+ classesStripped > 0
556
+ ? `, stripped ${classesStripped} source class name${classesStripped === 1 ? "" : "s"}`
557
+ : "";
558
+ progress("emit", `Wrote ${fileCount} files${classes}`);
559
+ }
560
+
561
+ /**
562
+ * Say which breakpoints the project is getting, and where the others went.
563
+ *
564
+ * The plan is a decision made ON THE AUTHOR'S BEHALF — nine declared widths become three — so it
565
+ * has to be visible. "9 breakpoints found, keeping 3" with the fold spelled out is the difference
566
+ * between a project that mysteriously has different breakpoints from its source and one whose
567
+ * import said so at the time.
568
+ */
569
+ function reportBreakpointPlan(mediaQueries: readonly string[]): void {
570
+ const skipped = skippedWidthQueries(mediaQueries);
571
+ if (skipped.length > 0) {
572
+ warn(
573
+ "styles",
574
+ `${skipped.length} width media ${skipped.length === 1 ? "query" : "queries"} could not be ` +
575
+ `read (only single-clause px min-width/max-width is supported): ${skipped.join(", ")}`,
576
+ );
577
+ }
578
+ const discovered = analyzeMediaQueries(mediaQueries);
579
+ if (discovered.length === 0) {
580
+ return;
581
+ }
582
+ const { keep, fold } = planBreakpoints(discovered, breakpointPolicy);
583
+ if (keep.length === discovered.length) {
584
+ progress("styles", `Keeping all ${keep.length} declared breakpoints`);
585
+ return;
586
+ }
587
+ const folded = new Map<string, string[]>();
588
+ for (const [from, to] of fold) {
589
+ if (from === to) {
590
+ continue;
591
+ }
592
+ folded.set(to, [...(folded.get(to) ?? []), from.replace(/^--/, "")]);
593
+ }
594
+ const detail = keep
595
+ .map((bp) => {
596
+ const into = folded.get(bp.name) ?? [];
597
+ return into.length > 0 ? `${bp.name} (+${into.join(", ")})` : bp.name;
598
+ })
599
+ .join(", ");
600
+ progress(
601
+ "styles",
602
+ `${discovered.length} breakpoints declared, keeping ${keep.length}: ${detail}`,
603
+ );
604
+ }
605
+ }