@jxsuite/import 0.34.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 +58 -0
- package/package.json +47 -0
- package/src/ai-componentize.ts +307 -0
- package/src/apply-styles.ts +103 -0
- package/src/asset-collect.ts +254 -0
- package/src/asset-download.ts +201 -0
- package/src/asset-rewrite.ts +143 -0
- package/src/capture.ts +159 -0
- package/src/cli.ts +153 -0
- package/src/componentize.ts +348 -0
- package/src/crawl.ts +391 -0
- package/src/css-tokens.ts +156 -0
- package/src/emit.ts +231 -0
- package/src/index.ts +53 -0
- package/src/layout-detect.ts +151 -0
- package/src/media-extract.ts +121 -0
- package/src/run.ts +528 -0
- package/src/screenshot-diff.ts +92 -0
- package/src/style-capture.ts +386 -0
- package/src/style-diff.ts +183 -0
- package/src/to-jx.ts +89 -0
- package/src/verify.ts +311 -0
package/src/crawl.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { launchBrowser, capturePage } from "./capture.ts";
|
|
2
|
+
import type { CaptureResult } from "./capture.ts";
|
|
3
|
+
import { convertToJx } from "./to-jx.ts";
|
|
4
|
+
import type { ToJxResult } from "./to-jx.ts";
|
|
5
|
+
import { captureStyles } from "./style-capture.ts";
|
|
6
|
+
import { diffAllStyles, kebabToCamel } from "./style-diff.ts";
|
|
7
|
+
import { extractMedia } from "./media-extract.ts";
|
|
8
|
+
import { applyStylesToTree } from "./apply-styles.ts";
|
|
9
|
+
import { collectAssets } from "./asset-collect.ts";
|
|
10
|
+
import { downloadAssets } from "./asset-download.ts";
|
|
11
|
+
import { rewriteAssetUrls } from "./asset-rewrite.ts";
|
|
12
|
+
import { applyTokens } from "./css-tokens.ts";
|
|
13
|
+
|
|
14
|
+
export interface CrawlOptions {
|
|
15
|
+
url: string;
|
|
16
|
+
outDir: string;
|
|
17
|
+
maxDepth: number;
|
|
18
|
+
maxPages: number;
|
|
19
|
+
maxNodesPerPage: number;
|
|
20
|
+
skipStyles: boolean;
|
|
21
|
+
skipAssets: boolean;
|
|
22
|
+
respectRobots: boolean;
|
|
23
|
+
/** Skip scroll-to-bottom before capture (default: false). */
|
|
24
|
+
noScroll?: boolean;
|
|
25
|
+
/** Capture a reference screenshot of each page before closing (for --verify). */
|
|
26
|
+
captureScreenshots?: boolean;
|
|
27
|
+
onProgress?: (msg: string) => void;
|
|
28
|
+
/** Abort the crawl between pages; the loop throws before capturing the next page. */
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CrawledPage {
|
|
33
|
+
url: string;
|
|
34
|
+
route: string;
|
|
35
|
+
title: string;
|
|
36
|
+
jx: ToJxResult;
|
|
37
|
+
depth: number;
|
|
38
|
+
links: string[];
|
|
39
|
+
/** Reference screenshot PNG buffer, present when captureScreenshots is true. */
|
|
40
|
+
screenshot?: Buffer | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CrawlResult {
|
|
44
|
+
pages: CrawledPage[];
|
|
45
|
+
breakpoints: Record<string, string> | undefined;
|
|
46
|
+
skippedByRobots: string[];
|
|
47
|
+
skippedByNodeCap: string[];
|
|
48
|
+
/** Collected @font-face rules from all pages (R2). */
|
|
49
|
+
fontFaceRules: string[];
|
|
50
|
+
/** Font URL → local path rewrite map (R2). */
|
|
51
|
+
fontRewriteMap: Map<string, string>;
|
|
52
|
+
/** CSS custom property tokens merged across all pages (R5). */
|
|
53
|
+
styleTokens: Record<string, string> | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a URL for deduplication: strip hash, trailing slash, tracking params, sort remaining
|
|
58
|
+
* params.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeUrl(raw: string): string {
|
|
61
|
+
try {
|
|
62
|
+
const u = new URL(raw);
|
|
63
|
+
u.hash = "";
|
|
64
|
+
|
|
65
|
+
const TRACKING_PARAMS = new Set([
|
|
66
|
+
"utm_source",
|
|
67
|
+
"utm_medium",
|
|
68
|
+
"utm_campaign",
|
|
69
|
+
"utm_term",
|
|
70
|
+
"utm_content",
|
|
71
|
+
"ref",
|
|
72
|
+
"fbclid",
|
|
73
|
+
"gclid",
|
|
74
|
+
"mc_cid",
|
|
75
|
+
"mc_eid",
|
|
76
|
+
]);
|
|
77
|
+
for (const p of TRACKING_PARAMS) {
|
|
78
|
+
u.searchParams.delete(p);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
u.searchParams.sort();
|
|
82
|
+
|
|
83
|
+
let path = u.pathname;
|
|
84
|
+
if (path.length > 1 && path.endsWith("/")) {
|
|
85
|
+
path = path.slice(0, -1);
|
|
86
|
+
}
|
|
87
|
+
u.pathname = path;
|
|
88
|
+
|
|
89
|
+
return u.toString();
|
|
90
|
+
} catch {
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert a URL path to a Jx page file path under pages/. / → pages/index.json /about →
|
|
97
|
+
* pages/about.json /blog/post → pages/blog/post.json
|
|
98
|
+
*/
|
|
99
|
+
export function routeToFilePath(url: string, _baseOrigin?: string): string {
|
|
100
|
+
const u = new URL(url);
|
|
101
|
+
let path = u.pathname;
|
|
102
|
+
|
|
103
|
+
if (path.length > 1 && path.endsWith("/")) {
|
|
104
|
+
path = path.slice(0, -1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (path === "" || path === "/") {
|
|
108
|
+
return "pages/index.json";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Remove leading slash, sanitize segments
|
|
112
|
+
const segments = path
|
|
113
|
+
.slice(1)
|
|
114
|
+
.split("/")
|
|
115
|
+
.map((s) => s.replaceAll(/[^a-zA-Z0-9._-]/g, "_"));
|
|
116
|
+
|
|
117
|
+
// If the last segment has an extension (like .html), strip it
|
|
118
|
+
const last = segments.at(-1)!;
|
|
119
|
+
const dotIdx = last.lastIndexOf(".");
|
|
120
|
+
if (dotIdx > 0) {
|
|
121
|
+
segments[segments.length - 1] = last.slice(0, dotIdx);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return `pages/${segments.join("/")}.json`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Fetch and parse robots.txt for a given origin. Returns a set of disallowed path prefixes. */
|
|
128
|
+
export async function fetchRobotsTxt(origin: string): Promise<Set<string>> {
|
|
129
|
+
const disallowed = new Set<string>();
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(`${origin}/robots.txt`);
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
return disallowed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const text = await res.text();
|
|
137
|
+
let inUserAgent = false;
|
|
138
|
+
for (const line of text.split("\n")) {
|
|
139
|
+
const trimmed = line.trim();
|
|
140
|
+
if (trimmed.startsWith("#") || trimmed === "") {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (trimmed.toLowerCase().startsWith("user-agent:")) {
|
|
145
|
+
const agent = trimmed.slice("user-agent:".length).trim();
|
|
146
|
+
inUserAgent = agent === "*";
|
|
147
|
+
} else if (inUserAgent && trimmed.toLowerCase().startsWith("disallow:")) {
|
|
148
|
+
const path = trimmed.slice("disallow:".length).trim();
|
|
149
|
+
if (path) {
|
|
150
|
+
disallowed.add(path);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Robots.txt unavailable — allow everything
|
|
156
|
+
}
|
|
157
|
+
return disallowed;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isDisallowed(url: string, disallowedPaths: Set<string>): boolean {
|
|
161
|
+
const path = new URL(url).pathname;
|
|
162
|
+
for (const prefix of disallowedPaths) {
|
|
163
|
+
if (path.startsWith(prefix)) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** BFS crawl a site starting from `url`. Captures each page, converts to Jx, applies styles/assets. */
|
|
171
|
+
export async function crawlSite(options: CrawlOptions): Promise<CrawlResult> {
|
|
172
|
+
const {
|
|
173
|
+
url,
|
|
174
|
+
outDir,
|
|
175
|
+
maxDepth,
|
|
176
|
+
maxPages,
|
|
177
|
+
maxNodesPerPage,
|
|
178
|
+
skipStyles,
|
|
179
|
+
skipAssets,
|
|
180
|
+
respectRobots,
|
|
181
|
+
onProgress = console.log,
|
|
182
|
+
} = options;
|
|
183
|
+
|
|
184
|
+
const { origin } = new URL(url);
|
|
185
|
+
const browser = await launchBrowser();
|
|
186
|
+
|
|
187
|
+
// Fetch robots.txt
|
|
188
|
+
let disallowedPaths = new Set<string>();
|
|
189
|
+
if (respectRobots) {
|
|
190
|
+
onProgress(" Fetching robots.txt...");
|
|
191
|
+
disallowedPaths = await fetchRobotsTxt(origin);
|
|
192
|
+
if (disallowedPaths.size > 0) {
|
|
193
|
+
onProgress(` robots.txt: ${disallowedPaths.size} disallowed paths`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const visited = new Set<string>();
|
|
198
|
+
const queue: { url: string; depth: number }[] = [{ url: normalizeUrl(url), depth: 0 }];
|
|
199
|
+
visited.add(normalizeUrl(url));
|
|
200
|
+
|
|
201
|
+
const pages: CrawledPage[] = [];
|
|
202
|
+
const skippedByRobots: string[] = [];
|
|
203
|
+
const skippedByNodeCap: string[] = [];
|
|
204
|
+
let mergedBreakpoints: Record<string, string> | undefined;
|
|
205
|
+
let mergedTokens: Record<string, string> | undefined;
|
|
206
|
+
const allFontFaceRules: string[] = [];
|
|
207
|
+
const fontRewriteMap = new Map<string, string>();
|
|
208
|
+
const seenFontRules = new Set<string>();
|
|
209
|
+
|
|
210
|
+
while (queue.length > 0 && pages.length < maxPages) {
|
|
211
|
+
if (options.signal?.aborted) {
|
|
212
|
+
throw new Error("Import aborted");
|
|
213
|
+
}
|
|
214
|
+
const entry = queue.shift()!;
|
|
215
|
+
|
|
216
|
+
if (respectRobots && isDisallowed(entry.url, disallowedPaths)) {
|
|
217
|
+
skippedByRobots.push(entry.url);
|
|
218
|
+
onProgress(` Skipped (robots.txt): ${entry.url}`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
onProgress(
|
|
223
|
+
` [${pages.length + 1}/${maxPages}] Capturing ${entry.url} (depth ${entry.depth})...`,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
let capture: CaptureResult;
|
|
227
|
+
try {
|
|
228
|
+
capture = await capturePage(entry.url, browser, { scrollToBottom: !options.noScroll });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
onProgress(
|
|
231
|
+
` ⚠ Failed to capture ${entry.url}: ${error instanceof Error ? error.message : error}`,
|
|
232
|
+
);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const jx = convertToJx(capture.bodyHtml);
|
|
237
|
+
onProgress(` Converted: ${jx.nodeCount} nodes`);
|
|
238
|
+
|
|
239
|
+
if (jx.nodeCount > maxNodesPerPage) {
|
|
240
|
+
onProgress(
|
|
241
|
+
` ⚠ Page exceeds node cap (${jx.nodeCount} > ${maxNodesPerPage}), skipping styles/assets`,
|
|
242
|
+
);
|
|
243
|
+
skippedByNodeCap.push(entry.url);
|
|
244
|
+
let screenshot: Buffer | undefined;
|
|
245
|
+
if (options.captureScreenshots) {
|
|
246
|
+
screenshot = Buffer.from(await capture.page.screenshot({ type: "png" }));
|
|
247
|
+
}
|
|
248
|
+
await capture.page.close();
|
|
249
|
+
|
|
250
|
+
pages.push({
|
|
251
|
+
url: entry.url,
|
|
252
|
+
route: routeToFilePath(entry.url, origin),
|
|
253
|
+
title: capture.title,
|
|
254
|
+
jx,
|
|
255
|
+
depth: entry.depth,
|
|
256
|
+
links: capture.links,
|
|
257
|
+
screenshot,
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Style capture (Phase 1)
|
|
263
|
+
if (!skipStyles) {
|
|
264
|
+
try {
|
|
265
|
+
const styleResult = await captureStyles(capture.page);
|
|
266
|
+
const diffed = diffAllStyles(styleResult.elements, styleResult.uaDefaults);
|
|
267
|
+
|
|
268
|
+
// R5: Replace resolved values with var(--name) references
|
|
269
|
+
if (Object.keys(styleResult.customProperties).length > 0) {
|
|
270
|
+
const tokenResult = applyTokens(diffed, styleResult.customProperties);
|
|
271
|
+
if (tokenResult.replacements > 0) {
|
|
272
|
+
if (!mergedTokens) {
|
|
273
|
+
mergedTokens = {};
|
|
274
|
+
}
|
|
275
|
+
Object.assign(mergedTokens, tokenResult.tokens);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (styleResult.mediaQueries.length > 0) {
|
|
280
|
+
const media = await extractMedia(
|
|
281
|
+
capture.page,
|
|
282
|
+
styleResult.elements,
|
|
283
|
+
styleResult.uaDefaults,
|
|
284
|
+
styleResult.mediaQueries,
|
|
285
|
+
);
|
|
286
|
+
const bpCount = Object.keys(media.breakpoints).length;
|
|
287
|
+
if (bpCount > 0) {
|
|
288
|
+
if (!mergedBreakpoints) {
|
|
289
|
+
mergedBreakpoints = {};
|
|
290
|
+
}
|
|
291
|
+
Object.assign(mergedBreakpoints, media.breakpoints);
|
|
292
|
+
applyStylesToTree(jx.document, diffed, media.deltas);
|
|
293
|
+
} else {
|
|
294
|
+
applyStylesToTree(jx.document, diffed);
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
applyStylesToTree(jx.document, diffed);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Apply <html>/<body> computed styles to the root wrapper
|
|
301
|
+
if (Object.keys(styleResult.documentStyles).length > 0) {
|
|
302
|
+
if (!jx.document.style) {
|
|
303
|
+
jx.document.style = {};
|
|
304
|
+
}
|
|
305
|
+
for (const [prop, val] of Object.entries(styleResult.documentStyles)) {
|
|
306
|
+
jx.document.style[kebabToCamel(prop)] = val;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
onProgress(` ⚠ Style capture failed: ${error instanceof Error ? error.message : error}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Asset download (Phase 2)
|
|
315
|
+
if (!skipAssets) {
|
|
316
|
+
try {
|
|
317
|
+
const collected = await collectAssets(capture.page);
|
|
318
|
+
|
|
319
|
+
// Accumulate @font-face rules (R2) — dedupe across pages
|
|
320
|
+
for (const sheet of collected.stylesheets) {
|
|
321
|
+
for (const rule of sheet.fontFaceRules) {
|
|
322
|
+
if (!seenFontRules.has(rule)) {
|
|
323
|
+
seenFontRules.add(rule);
|
|
324
|
+
allFontFaceRules.push(rule);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (collected.assets.length > 0) {
|
|
330
|
+
const downloaded = await downloadAssets(collected.assets, outDir, entry.url);
|
|
331
|
+
if (downloaded.rewriteMap.size > 0) {
|
|
332
|
+
rewriteAssetUrls(jx.document, downloaded.rewriteMap, entry.url);
|
|
333
|
+
// Collect font rewrites (R2)
|
|
334
|
+
for (const [originalUrl, localPath] of downloaded.rewriteMap) {
|
|
335
|
+
if (localPath.includes("/fonts/") || /\.(woff2?|ttf|otf|eot)$/i.test(localPath)) {
|
|
336
|
+
fontRewriteMap.set(originalUrl, localPath);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
onProgress(
|
|
343
|
+
` ⚠ Asset collection failed: ${error instanceof Error ? error.message : error}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let screenshot: Buffer | undefined;
|
|
349
|
+
if (options.captureScreenshots) {
|
|
350
|
+
screenshot = Buffer.from(await capture.page.screenshot({ type: "png" }));
|
|
351
|
+
}
|
|
352
|
+
await capture.page.close();
|
|
353
|
+
|
|
354
|
+
const route = routeToFilePath(entry.url, origin);
|
|
355
|
+
pages.push({
|
|
356
|
+
url: entry.url,
|
|
357
|
+
route,
|
|
358
|
+
title: capture.title,
|
|
359
|
+
jx,
|
|
360
|
+
depth: entry.depth,
|
|
361
|
+
links: capture.links,
|
|
362
|
+
screenshot,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Enqueue discovered links (BFS)
|
|
366
|
+
if (entry.depth < maxDepth) {
|
|
367
|
+
for (const link of capture.links) {
|
|
368
|
+
const normalized = normalizeUrl(link);
|
|
369
|
+
if (!normalized.startsWith(origin)) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (visited.has(normalized)) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
visited.add(normalized);
|
|
377
|
+
queue.push({ url: normalized, depth: entry.depth + 1 });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
pages,
|
|
384
|
+
breakpoints: mergedBreakpoints,
|
|
385
|
+
skippedByRobots,
|
|
386
|
+
skippedByNodeCap,
|
|
387
|
+
fontFaceRules: allFontFaceRules,
|
|
388
|
+
fontRewriteMap,
|
|
389
|
+
styleTokens: mergedTokens,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract CSS custom properties from retained stylesheets and replace resolved computed values with
|
|
3
|
+
* var(--name) references. Hoists token declarations for project.json.$style.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { DiffedStyle } from "./style-diff.ts";
|
|
7
|
+
|
|
8
|
+
export interface TokenExtractionResult {
|
|
9
|
+
/**
|
|
10
|
+
* Custom property declarations to hoist into project.json.$style (e.g. { "--brand-blue":
|
|
11
|
+
* "#3b82f6" }).
|
|
12
|
+
*/
|
|
13
|
+
tokens: Record<string, string>;
|
|
14
|
+
/** Number of value replacements made across all diffed styles. */
|
|
15
|
+
replacements: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalize a CSS color value for comparison. Handles common variations: - rgb(r, g, b) vs rgb(r g
|
|
20
|
+
* b) - Trailing semicolons/whitespace
|
|
21
|
+
*/
|
|
22
|
+
function normalizeValue(val: string): string {
|
|
23
|
+
return val
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/\s*;\s*$/, "")
|
|
26
|
+
.replaceAll(/,\s*/g, ", ")
|
|
27
|
+
.replaceAll(/\s+/g, " ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a reverse map: resolved CSS value → var(--name). When multiple custom properties resolve to
|
|
32
|
+
* the same value, prefer shorter names (heuristic: shorter names are more likely to be semantic
|
|
33
|
+
* tokens).
|
|
34
|
+
*/
|
|
35
|
+
function buildValueToVarMap(props: Map<string, string>): Map<string, string> {
|
|
36
|
+
const valueToVar = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
// Sort by name length ascending so shorter (more semantic) names win ties
|
|
39
|
+
const sorted = [...props.entries()].toSorted((a, b) => a[0].length - b[0].length);
|
|
40
|
+
|
|
41
|
+
for (const [name, value] of sorted) {
|
|
42
|
+
const normalized = normalizeValue(value);
|
|
43
|
+
if (!valueToVar.has(normalized)) {
|
|
44
|
+
valueToVar.set(normalized, `var(${name})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return valueToVar;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TOKEN_ELIGIBLE_PROPS = new Set([
|
|
52
|
+
"color",
|
|
53
|
+
"backgroundColor",
|
|
54
|
+
"borderColor",
|
|
55
|
+
"borderTopColor",
|
|
56
|
+
"borderRightColor",
|
|
57
|
+
"borderBottomColor",
|
|
58
|
+
"borderLeftColor",
|
|
59
|
+
"outlineColor",
|
|
60
|
+
"textDecorationColor",
|
|
61
|
+
"fill",
|
|
62
|
+
"stroke",
|
|
63
|
+
"caretColor",
|
|
64
|
+
"columnRuleColor",
|
|
65
|
+
"boxShadow",
|
|
66
|
+
"textShadow",
|
|
67
|
+
"background",
|
|
68
|
+
"backgroundImage",
|
|
69
|
+
"borderTop",
|
|
70
|
+
"borderRight",
|
|
71
|
+
"borderBottom",
|
|
72
|
+
"borderLeft",
|
|
73
|
+
"border",
|
|
74
|
+
"fontFamily",
|
|
75
|
+
"fontSize",
|
|
76
|
+
"fontWeight",
|
|
77
|
+
"lineHeight",
|
|
78
|
+
"letterSpacing",
|
|
79
|
+
"gap",
|
|
80
|
+
"rowGap",
|
|
81
|
+
"columnGap",
|
|
82
|
+
"borderRadius",
|
|
83
|
+
"borderTopLeftRadius",
|
|
84
|
+
"borderTopRightRadius",
|
|
85
|
+
"borderBottomLeftRadius",
|
|
86
|
+
"borderBottomRightRadius",
|
|
87
|
+
"padding",
|
|
88
|
+
"paddingTop",
|
|
89
|
+
"paddingRight",
|
|
90
|
+
"paddingBottom",
|
|
91
|
+
"paddingLeft",
|
|
92
|
+
"margin",
|
|
93
|
+
"marginTop",
|
|
94
|
+
"marginRight",
|
|
95
|
+
"marginBottom",
|
|
96
|
+
"marginLeft",
|
|
97
|
+
"maxWidth",
|
|
98
|
+
"width",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replace resolved computed values with var(--name) references in diffed styles. Also collects the
|
|
103
|
+
* set of custom properties actually used (for hoisting into project.json).
|
|
104
|
+
*
|
|
105
|
+
* Mutates the diffed styles in place for efficiency.
|
|
106
|
+
*
|
|
107
|
+
* @param customProperties - Map of CSS custom property name → resolved value, as extracted by
|
|
108
|
+
* captureStyles() in-browser.
|
|
109
|
+
*/
|
|
110
|
+
export function applyTokens(
|
|
111
|
+
diffedStyles: DiffedStyle[],
|
|
112
|
+
customProperties: Record<string, string>,
|
|
113
|
+
): TokenExtractionResult {
|
|
114
|
+
const props = new Map(Object.entries(customProperties));
|
|
115
|
+
if (props.size === 0) {
|
|
116
|
+
return { tokens: {}, replacements: 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const valueToVar = buildValueToVarMap(props);
|
|
120
|
+
const usedTokens = new Map<string, string>();
|
|
121
|
+
let replacements = 0;
|
|
122
|
+
|
|
123
|
+
for (const el of diffedStyles) {
|
|
124
|
+
for (const [prop, value] of Object.entries(el.style)) {
|
|
125
|
+
if (!TOKEN_ELIGIBLE_PROPS.has(prop)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (typeof value !== "string") {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Already a var() reference
|
|
132
|
+
if (value.startsWith("var(")) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const normalized = normalizeValue(value);
|
|
137
|
+
const varRef = valueToVar.get(normalized);
|
|
138
|
+
if (varRef) {
|
|
139
|
+
el.style[prop] = varRef;
|
|
140
|
+
replacements += 1;
|
|
141
|
+
|
|
142
|
+
// Track which token was used so we can hoist its declaration
|
|
143
|
+
const varName = varRef.slice(4, -1); // "var(--x)" → "--x"
|
|
144
|
+
usedTokens.set(varName, props.get(varName)!);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Sort tokens alphabetically for stable output
|
|
150
|
+
const tokens: Record<string, string> = {};
|
|
151
|
+
for (const name of [...usedTokens.keys()].toSorted()) {
|
|
152
|
+
tokens[name] = usedTokens.get(name)!;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { tokens, replacements };
|
|
156
|
+
}
|