tw-migrate 0.2.0 → 0.4.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/index.js DELETED
@@ -1,2225 +0,0 @@
1
- import { execFile } from "node:child_process";
2
- import { createRequire } from "node:module";
3
- import {
4
- chmod,
5
- lstat,
6
- readFile,
7
- readdir,
8
- realpath,
9
- rename,
10
- rm,
11
- stat,
12
- writeFile,
13
- } from "node:fs/promises";
14
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
- import { pathToFileURL } from "node:url";
16
- import { promisify } from "node:util";
17
-
18
- import { parseHtmlSource, utf8OffsetMap } from "./html.js";
19
- import { planBatchMigration, staticImports, validateCss } from "./native.js";
20
- import { analyzeVueSource, loadProjectVueCompiler, verifyVueSource } from "./vue.js";
21
- import {
22
- compileLessEntry,
23
- compileSassEntry,
24
- isPreprocessorPath,
25
- isSassPath,
26
- loadProjectLess,
27
- loadProjectSass,
28
- } from "./style-compiler.js";
29
-
30
- const run = promisify(execFile);
31
- const SOURCE_EXTENSIONS = new Set([
32
- ".html",
33
- ".vue",
34
- ".js",
35
- ".jsx",
36
- ".mjs",
37
- ".cjs",
38
- ".ts",
39
- ".tsx",
40
- ".mts",
41
- ".cts",
42
- ]);
43
- const STYLESHEET_SYNTAX = new Map([
44
- [".css", "css"],
45
- [".scss", "scss"],
46
- [".sass", "sass"],
47
- [".less", "less"],
48
- ]);
49
- const IGNORED_DIRECTORIES = new Set([".git", ".next", "build", "dist", "node_modules"]);
50
- const RECOVERABLE_INPUT_ERROR = "TW_MIGRATE_RECOVERABLE_INPUT:";
51
- const compareStrings = (left, right) => (left > right) - (left < right);
52
-
53
- export async function migrate(options = {}) {
54
- if ("cssFile" in options) {
55
- throw new TypeError("cssFile has been replaced by styleFile");
56
- }
57
- if (options.styleFile && options.workspaces) {
58
- throw new TypeError("styleFile cannot be combined with workspaces");
59
- }
60
- if (options.tailwindCss && options.workspaces) {
61
- throw new TypeError("tailwindCss cannot be combined with workspaces");
62
- }
63
-
64
- const scope = await resolveScope(options);
65
- const { cwd, workspaceRoot, selectedPackages, explicitStyle, configuredEntry } = scope;
66
-
67
- // A prior interrupted run's scope is unknowable from the current flags
68
- // (it may have covered other packages), so scan the whole workspace root.
69
- const leftovers = await collectFiles(workspaceRoot, (path) =>
70
- basename(path).includes(".tw-migrate-"),
71
- );
72
- if (leftovers.length > 0) {
73
- const listed = leftovers
74
- .sort(compareStrings)
75
- .map((path) => ` ${normalizedRelativePath(cwd, path)}`)
76
- .join("\n");
77
- throw new Error(
78
- `Found leftover tw-migrate files from an interrupted run:\n${listed}\n` +
79
- 'Restore each ".<name>.tw-migrate-backup-*" file by renaming it back to "<name>", ' +
80
- 'delete any remaining ".<name>.tw-migrate-*" staging files, then re-run.',
81
- );
82
- }
83
-
84
- const snapshots = new Map();
85
- const stylePaths = scope.scannedPaths.filter(isStylesheetPath);
86
- const sourcePaths = scope.scannedPaths.filter((path) => SOURCE_EXTENSIONS.has(extension(path)));
87
- const [styleSources, sourceCandidates] = await Promise.all([
88
- readSources(stylePaths, snapshots),
89
- Promise.all(
90
- sourcePaths.map(async (path) => {
91
- const source = await readFile(path, "utf8");
92
- // Scan-only scripts are always retained as reference-only inputs: even
93
- // without a ".module." mention they can render components whose trees
94
- // the closed-world relationship proofs must see. Scan-only HTML matters
95
- // solely as a potential stylesheet consumer, and HTML entities can
96
- // encode any part of a linked filename, so retain ignored HTML
97
- // containing a link for parse5 to classify safely.
98
- const mayReferenceModule = extension(path) !== ".html" || /<link\b/i.test(source);
99
- if (!scope.targetable.has(path) && !mayReferenceModule) return undefined;
100
- return { path, source: recordSnapshot(snapshots, path, source) };
101
- }),
102
- ),
103
- ...selectedPackages.map((packageRoot) =>
104
- snapshotFile(snapshots, join(packageRoot, "package.json")),
105
- ),
106
- ]);
107
- const sourceFiles = sourceCandidates.filter(Boolean);
108
- // An explicit .vue selection is a source file, not a stylesheet input; only
109
- // real stylesheets may enter the stylesheet maps.
110
- if (explicitStyle && isStylesheetPath(explicitStyle) && !styleSources.has(explicitStyle)) {
111
- styleSources.set(explicitStyle, await snapshotFile(snapshots, explicitStyle));
112
- }
113
- if (configuredEntry && !styleSources.has(configuredEntry)) {
114
- styleSources.set(configuredEntry, await snapshotFile(snapshots, configuredEntry));
115
- }
116
-
117
- const context = {
118
- ...scope,
119
- options,
120
- snapshots,
121
- styleSources,
122
- sourceFiles,
123
- styleDependents: indexStylesheetDependents(styleSources),
124
- };
125
- const failures = [];
126
- const plans = [];
127
- for (const packageRoot of selectedPackages) {
128
- const result = await planPackage(context, packageRoot);
129
- if (result.failure) failures.push(result.failure);
130
- else if (result.plan) plans.push(result.plan);
131
- }
132
-
133
- const originals = new Map([
134
- ...styleSources,
135
- ...sourceFiles.map((file) => [file.path, file.source]),
136
- ]);
137
- const { filesByPath, deletedPaths, candidates, rules, warnings, convertedRules, retainedRules } =
138
- mergePlans(plans, originals);
139
-
140
- const changed = [...filesByPath.values()]
141
- .map((file) => ({ ...file, before: originals.get(file.path) }))
142
- .filter((file) => file.before !== file.source)
143
- .sort((left, right) => left.path.localeCompare(right.path));
144
- const deleted = [...deletedPaths]
145
- .map((path) => ({ path, before: originals.get(path) }))
146
- .sort((left, right) => left.path.localeCompare(right.path));
147
- const operations = [...changed, ...deleted].sort((left, right) =>
148
- left.path.localeCompare(right.path),
149
- );
150
- const changedFiles = operations.map((file) => normalizedRelativePath(cwd, file.path));
151
- const diff = operations
152
- .map((file) =>
153
- unifiedDiff(
154
- normalizedRelativePath(cwd, file.path),
155
- file.before,
156
- "source" in file ? file.source : "",
157
- ),
158
- )
159
- .join("");
160
-
161
- if (options.write && operations.length > 0) {
162
- await verifySnapshots(snapshots);
163
- await writeChanges(changed, deleted);
164
- }
165
-
166
- warnings.sort(
167
- (left, right) =>
168
- left.file.localeCompare(right.file) ||
169
- left.start - right.start ||
170
- left.end - right.end ||
171
- left.code.localeCompare(right.code),
172
- );
173
- failures.sort((left, right) => left.package.localeCompare(right.package));
174
- return {
175
- changedFiles,
176
- diff,
177
- convertedRules,
178
- retainedRules,
179
- // `stylesheet` is internal compile-failure attribution, not public shape.
180
- rules: rules.map(({ stylesheet: _, ...rule }) => ({
181
- ...rule,
182
- file: normalizedRelativePath(cwd, rule.file),
183
- })),
184
- candidates: [...candidates].sort(compareStrings),
185
- warnings: warnings.map((warning) => ({
186
- ...warning,
187
- file: normalizedRelativePath(cwd, warning.file),
188
- })),
189
- failures,
190
- };
191
- }
192
-
193
- async function resolveScope(options) {
194
- const cwd = await realpath(resolve(options.cwd ?? process.cwd()));
195
- const currentPackage = await findPackageRoot(cwd);
196
- const gitRoot = await findGitRoot(currentPackage);
197
- const workspaceRoot =
198
- gitRoot && !(await isIgnoredByGit(gitRoot, currentPackage)) ? gitRoot : currentPackage;
199
- const allPaths = await discoverFiles(workspaceRoot, workspaceRoot === gitRoot);
200
- // Ignore filtering scopes what gets migrated, never what gets scanned:
201
- // gitignored consumers and stylesheets must still block unsafe deletion.
202
- const scannedPaths =
203
- workspaceRoot === gitRoot
204
- ? [
205
- ...new Set([
206
- ...allPaths,
207
- ...(await collectFiles(workspaceRoot, isRelevantDiscoveredFile)),
208
- ]),
209
- ]
210
- : [...allPaths];
211
- const explicitStyle = options.styleFile ? resolve(cwd, options.styleFile) : undefined;
212
- const configuredEntry = options.tailwindCss ? resolve(cwd, options.tailwindCss) : undefined;
213
- if (explicitStyle && !isStylesheetPath(explicitStyle) && extension(explicitStyle) !== ".vue") {
214
- throw new TypeError("Only .css, .scss, .sass, .less, and .vue files can be migrated");
215
- }
216
- if (configuredEntry && extension(configuredEntry) !== ".css") {
217
- throw new TypeError("The Tailwind CSS entry must be a .css file");
218
- }
219
- if (configuredEntry && !(await stat(configuredEntry)).isFile()) {
220
- throw new TypeError("The Tailwind CSS entry must be a file");
221
- }
222
- for (const path of [explicitStyle, configuredEntry]) {
223
- if (path) await rejectSymlinkTarget(path, currentPackage);
224
- }
225
- if (explicitStyle && !isWithin(currentPackage, explicitStyle)) {
226
- throw new TypeError("The selected stylesheet must belong to the current package");
227
- }
228
- for (const path of [explicitStyle, configuredEntry]) {
229
- if (path && !allPaths.includes(path)) allPaths.push(path);
230
- if (path && !scannedPaths.includes(path)) scannedPaths.push(path);
231
- }
232
- allPaths.sort(compareStrings);
233
- scannedPaths.sort(compareStrings);
234
- const targetable = new Set(allPaths);
235
-
236
- const allPackageRoots = await discoverPackageRoots(workspaceRoot, allPaths);
237
- if (!allPackageRoots.includes(currentPackage)) allPackageRoots.push(currentPackage);
238
- for (const path of [explicitStyle, configuredEntry]) {
239
- if (!path) continue;
240
- const owner = await findPackageRoot(dirname(path));
241
- if (!allPackageRoots.includes(owner)) allPackageRoots.push(owner);
242
- }
243
- allPackageRoots.sort(compareStrings);
244
- const pathOwners = new Map(
245
- scannedPaths.map((path) => [path, owningPackage(path, allPackageRoots)]),
246
- );
247
- if (explicitStyle && pathOwners.get(explicitStyle) !== currentPackage) {
248
- throw new TypeError("The selected stylesheet must belong to the current package");
249
- }
250
- if (configuredEntry && pathOwners.get(configuredEntry) !== currentPackage) {
251
- throw new TypeError("The Tailwind CSS entry must belong to the current package");
252
- }
253
- const selectedPackages = options.workspaces ? allPackageRoots : [currentPackage];
254
- return {
255
- cwd,
256
- workspaceRoot,
257
- scannedPaths,
258
- targetable,
259
- explicitStyle,
260
- configuredEntry,
261
- pathOwners,
262
- selectedPackages,
263
- writablePackages: new Set(selectedPackages),
264
- };
265
- }
266
-
267
- async function planPackage(context, packageRoot) {
268
- const {
269
- options,
270
- snapshots,
271
- workspaceRoot,
272
- explicitStyle,
273
- configuredEntry,
274
- styleSources,
275
- sourceFiles,
276
- styleDependents,
277
- pathOwners,
278
- targetable,
279
- writablePackages,
280
- scannedPaths,
281
- } = context;
282
- let preparedHtml;
283
- try {
284
- preparedHtml = await preparePackageHtml({
285
- packageRoot,
286
- sourceFiles,
287
- styleSources,
288
- snapshots,
289
- pathOwners,
290
- styleDependents,
291
- });
292
- } catch (error) {
293
- if (!options.force || isIntegrityError(error)) throw error;
294
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
295
- }
296
- let preparedVue;
297
- try {
298
- preparedVue = await preparePackageVue({
299
- packageRoot,
300
- sourceFiles,
301
- styleSources,
302
- pathOwners,
303
- targetable,
304
- explicitStyle,
305
- snapshots,
306
- workspaceRoot,
307
- workspaces: options.workspaces,
308
- scannedPaths,
309
- });
310
- } catch (error) {
311
- if (!options.force || isIntegrityError(error)) throw error;
312
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
313
- }
314
- // An explicit .vue selection that produced nothing to plan must surface
315
- // its retention warnings without requiring an unrelated Tailwind entry.
316
- if (
317
- explicitStyle &&
318
- extension(explicitStyle) === ".vue" &&
319
- preparedVue.stylesheets.length === 0 &&
320
- ![...preparedVue.stylePaths].some((path) => !isStylesheetModule(path))
321
- ) {
322
- return vueWarningsOnlyResult(preparedVue);
323
- }
324
- const packageSources = [
325
- ...sourceFiles
326
- .filter((file) => extension(file.path) !== ".html")
327
- .map((file) => preparedVue.files.get(file.path) ?? file),
328
- ...preparedHtml.files,
329
- ];
330
- const ownedStyles = [...styleSources.keys()].filter(
331
- (path) =>
332
- pathOwners.get(path) === packageRoot &&
333
- (targetable.has(path) ||
334
- preparedHtml.stylePaths.has(path) ||
335
- preparedVue.stylePaths.has(path)),
336
- );
337
- if (ownedStyles.length === 0 && preparedVue.stylesheets.length === 0) {
338
- return vueWarningsOnlyResult(preparedVue);
339
- }
340
-
341
- let tailwindPath;
342
- let tailwindEntries;
343
- try {
344
- ({ path: tailwindPath, entries: tailwindEntries } = resolveTailwindEntry(
345
- ownedStyles,
346
- styleSources,
347
- configuredEntry,
348
- ));
349
- } catch (error) {
350
- if (!options.force) throw error;
351
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
352
- }
353
-
354
- const excludedEntries = new Set([...tailwindEntries, tailwindPath]);
355
- const explicitCss =
356
- explicitStyle && extension(explicitStyle) !== ".vue" ? explicitStyle : undefined;
357
- const targets = explicitCss
358
- ? [explicitCss]
359
- : explicitStyle
360
- ? [...preparedVue.stylePaths].filter(
361
- (path) => !isStylesheetModule(path) && !excludedEntries.has(path),
362
- )
363
- : ownedStyles.filter(
364
- (path) =>
365
- !excludedEntries.has(path) &&
366
- !preparedHtml.generatedPaths.has(path) &&
367
- (!isPreprocessorPath(path) ||
368
- preparedHtml.stylePaths.has(path) ||
369
- packageSources.some((file) => sourceReferencesStyle(file, path))),
370
- );
371
- if (targets.length === 0 && preparedVue.stylesheets.length === 0) {
372
- return vueWarningsOnlyResult(preparedVue);
373
- }
374
- if (targets.some((path) => excludedEntries.has(path))) {
375
- throw new Error("The Tailwind CSS entry cannot be migrated.");
376
- }
377
-
378
- let tailwind;
379
- try {
380
- tailwind = await loadTailwind(packageRoot, tailwindPath, snapshots, workspaceRoot);
381
- } catch (error) {
382
- if (!options.force || isIntegrityError(error)) throw error;
383
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
384
- }
385
-
386
- const files = packageSources.map((file) => {
387
- const owner = pathOwners.get(file.path);
388
- return {
389
- ...file,
390
- writable:
391
- targetable.has(file.path) &&
392
- (options.workspaces ? writablePackages.has(owner) : owner === packageRoot),
393
- };
394
- });
395
- let stylesheets;
396
- let sass;
397
- let less;
398
- try {
399
- stylesheets = [];
400
- const compilerDependents = new Map();
401
- for (const stylePath of targets.sort(compareStrings)) {
402
- await rejectSymlinkTarget(stylePath, packageRoot);
403
- const isPartial = isSassPath(stylePath) && basename(stylePath).startsWith("_");
404
- const stylesheet = {
405
- cssPath: stylePath,
406
- cssSource: styleSources.get(stylePath),
407
- cssModuleId: normalizedRelativePath(packageRoot, stylePath),
408
- cssDependents: styleDependents.get(stylePath) ?? [],
409
- syntax: stylesheetSyntax(stylePath),
410
- isModule: isStylesheetModule(stylePath),
411
- isPartial,
412
- };
413
- let compiled;
414
- if (isSassPath(stylePath) && !isPartial) {
415
- sass ??= await loadProjectSass(packageRoot);
416
- // Compile the snapshotted source, not the on-disk file: code loaded
417
- // during planning (e.g. Tailwind plugins) may have rewritten it since.
418
- compiled = await compileSassEntry(sass, stylePath, stylesheet.cssSource);
419
- } else if (extension(stylePath) === ".less") {
420
- less ??= await loadProjectLess(packageRoot);
421
- compiled = await compileLessEntry(less, stylePath, stylesheet.cssSource);
422
- }
423
- if (compiled) {
424
- validateCss(compiled.css);
425
- for (const loadedPath of compiled.loadedPaths) {
426
- if (!isProjectInput(workspaceRoot, loadedPath)) continue;
427
- const source = await snapshotLoadedSource(snapshots, loadedPath);
428
- if (!styleSources.has(loadedPath)) styleSources.set(loadedPath, source);
429
- if (loadedPath !== stylePath) {
430
- const dependents = compilerDependents.get(loadedPath) ?? [];
431
- dependents.push(stylePath);
432
- compilerDependents.set(loadedPath, dependents);
433
- }
434
- }
435
- stylesheet.analysisSource = compiled.css;
436
- stylesheet.sourceMappings = compiled.sourceMappings;
437
- }
438
- stylesheets.push(stylesheet);
439
- }
440
- for (const stylesheet of stylesheets) {
441
- const dependents = compilerDependents.get(stylesheet.cssPath) ?? [];
442
- if (dependents.length === 0) continue;
443
- stylesheet.isPartial = true;
444
- stylesheet.cssDependents = [...new Set([...stylesheet.cssDependents, ...dependents])].sort(
445
- compareStrings,
446
- );
447
- }
448
- } catch (error) {
449
- if (!options.force || isIntegrityError(error)) throw error;
450
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
451
- }
452
-
453
- stylesheets.push(...preparedVue.stylesheets);
454
- const request = {
455
- stylesheets,
456
- tailwindPath: tailwind.path,
457
- tailwindSource: tailwind.css,
458
- utilityPrefix: tailwind.designSystem.theme.prefix,
459
- themeTokens: tailwind.themeTokens,
460
- files,
461
- };
462
- let plan;
463
- try {
464
- plan = JSON.parse(planBatchMigration(JSON.stringify(request)));
465
- } catch (error) {
466
- if (!options.force || !isRecoverablePlanningError(error)) throw error;
467
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
468
- }
469
-
470
- try {
471
- plan = replanCompileFailures(tailwind, request, plan);
472
- } catch (error) {
473
- if (!options.force) throw error;
474
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
475
- }
476
-
477
- removeMigratedHtmlLinks(plan, preparedHtml);
478
- plan.warnings.push(...preparedHtml.warnings);
479
- plan.warnings.push(...preparedVue.warnings);
480
- try {
481
- for (const file of plan.files.filter((file) => extension(file.path) === ".html")) {
482
- parseHtmlSource(file.path, file.source);
483
- }
484
- for (const file of plan.files.filter((file) => extension(file.path) === ".vue")) {
485
- const includeUnscoped = preparedVue.unscopedPaths.has(file.path);
486
- const blocks = verifyVueSource(preparedVue.compiler, file.path, file.source, includeUnscoped);
487
- // Blocks the migration never edited are byte-identical to the authored
488
- // input and need no recompilation; a caller that only received a
489
- // template edit must not suddenly require a preprocessor.
490
- const untouched = new Map();
491
- for (const block of verifyVueSource(
492
- preparedVue.compiler,
493
- file.path,
494
- snapshots.get(file.path),
495
- includeUnscoped,
496
- )) {
497
- const key = `${block.syntax}\0${block.content}`;
498
- untouched.set(key, (untouched.get(key) ?? 0) + 1);
499
- }
500
- for (const [index, block] of blocks.entries()) {
501
- const key = `${block.syntax}\0${block.content}`;
502
- const remaining = untouched.get(key) ?? 0;
503
- if (remaining > 0) {
504
- untouched.set(key, remaining - 1);
505
- continue;
506
- }
507
- if (block.syntax === "css") {
508
- validateCss(block.content);
509
- } else if (block.syntax === "less") {
510
- validateCss(
511
- (
512
- await compileLessEntry(
513
- (preparedVue.less ??= await loadProjectLess(packageRoot)),
514
- `${file.path}.${index}.less`,
515
- block.content,
516
- )
517
- ).css,
518
- );
519
- } else {
520
- validateCss(
521
- (
522
- await compileSassEntry(
523
- (preparedVue.sass ??= await loadProjectSass(packageRoot)),
524
- `${file.path}.${index}.${block.syntax}`,
525
- block.content,
526
- { virtualEntry: true },
527
- )
528
- ).css,
529
- );
530
- }
531
- }
532
- }
533
- for (const stylesheet of stylesheets.filter((stylesheet) =>
534
- isPreprocessorPath(stylesheet.cssPath),
535
- )) {
536
- const changed = plan.files.find((file) => file.path === stylesheet.cssPath);
537
- if (!changed && !plan.deletedFiles.includes(stylesheet.cssPath)) continue;
538
- const source = changed?.source ?? "";
539
- if (isSassPath(stylesheet.cssPath)) {
540
- validateCss((await compileSassEntry(sass, stylesheet.cssPath, source)).css);
541
- } else {
542
- validateCss((await compileLessEntry(less, stylesheet.cssPath, source)).css);
543
- }
544
- }
545
- } catch (error) {
546
- if (!options.force || !isMissingStyleCompilerError(error)) throw error;
547
- return { failure: packageFailure(workspaceRoot, packageRoot, error) };
548
- }
549
- return { plan };
550
- }
551
-
552
- // A candidate Tailwind refuses to compile retains its owning rule(s) instead
553
- // of aborting the run: block those rules and replan until every applied
554
- // candidate compiles. Each iteration blocks at least one new rule, so the
555
- // loop is bounded by the rule count; if a failing candidate cannot be
556
- // attributed to a new rule, fall back to the package-level failure path.
557
- function replanCompileFailures(tailwind, request, initialPlan) {
558
- let plan = initialPlan;
559
- const blockedByStylesheet = new Map();
560
- const maxIterations = plan.rules.length + 1;
561
- for (let iteration = 0; ; iteration += 1) {
562
- const failing = invalidCandidates(tailwind, plan.candidates);
563
- if (failing.length === 0) break;
564
- let progressed = false;
565
- for (const rule of plan.rules) {
566
- const failed = rule.candidates.filter((candidate) => failing.includes(candidate));
567
- if (failed.length === 0) continue;
568
- // Keyed by entry index, not cssPath: same-path Vue entries (scoped and
569
- // module blocks) reuse local rule spans, so path-level attribution
570
- // would block unrelated rules in the sibling entry.
571
- let blocked = blockedByStylesheet.get(rule.stylesheet);
572
- if (!blocked) blockedByStylesheet.set(rule.stylesheet, (blocked = new Map()));
573
- const key = `${rule.ruleId.start}-${rule.ruleId.end}`;
574
- let entry = blocked.get(key);
575
- if (!entry) {
576
- blocked.set(
577
- key,
578
- (entry = { ruleId: rule.ruleId, authoredSpan: rule.authoredSpan, candidates: new Set() }),
579
- );
580
- progressed = true;
581
- }
582
- for (const candidate of failed) entry.candidates.add(candidate);
583
- }
584
- if (!progressed || iteration >= maxIterations) {
585
- throw new Error(`Tailwind did not generate CSS for candidate: ${failing[0]}`);
586
- }
587
- plan = JSON.parse(
588
- planBatchMigration(
589
- JSON.stringify({
590
- ...request,
591
- stylesheets: request.stylesheets.map((stylesheet, index) => ({
592
- ...stylesheet,
593
- blockedRules: [...(blockedByStylesheet.get(index)?.values() ?? [])].map(
594
- (entry) => entry.ruleId,
595
- ),
596
- })),
597
- }),
598
- ),
599
- );
600
- }
601
- for (const [index, blocked] of blockedByStylesheet) {
602
- const cssPath = request.stylesheets[index].cssPath;
603
- for (const { authoredSpan, candidates } of blocked.values()) {
604
- const failed = [...candidates]
605
- .sort(compareStrings)
606
- .map((candidate) => `\`${candidate}\``)
607
- .join(", ");
608
- plan.warnings.push({
609
- code: "candidate-compilation-failure",
610
- file: cssPath,
611
- // ruleId is a compiled-domain span; warnings anchor to the authored file.
612
- start: authoredSpan.start,
613
- end: authoredSpan.end,
614
- message: `Tailwind did not generate CSS for ${failed}, so the rule is retained.`,
615
- });
616
- }
617
- }
618
- return plan;
619
- }
620
-
621
- function removeMigratedHtmlLinks(plan, preparedHtml) {
622
- const unlinked = new Set(plan.unlinkedFiles);
623
- const linksByFile = new Map();
624
- for (const link of preparedHtml.removableLinks) {
625
- if (!unlinked.has(link.cssPath)) continue;
626
- const links = linksByFile.get(link.filePath) ?? new Set();
627
- links.add(`${link.href}\0${link.media}`);
628
- linksByFile.set(link.filePath, links);
629
- }
630
-
631
- for (const [filePath, removable] of linksByFile) {
632
- const planned = plan.files.find((file) => file.path === filePath);
633
- const original = preparedHtml.files.find((file) => file.path === filePath);
634
- if (!original) continue;
635
- const source = planned?.source ?? original.source;
636
- const links = parseHtmlSource(filePath, source)
637
- .links.filter((link) => removable.has(`${link.href}\0${link.media}`))
638
- .sort((left, right) => right.tagStart - left.tagStart);
639
- let bytes = Buffer.from(source);
640
- for (const link of links) {
641
- bytes = Buffer.concat([bytes.subarray(0, link.tagStart), bytes.subarray(link.tagEnd)]);
642
- }
643
- const updated = bytes.toString();
644
- if (updated === source) continue;
645
- if (planned) planned.source = updated;
646
- else plan.files.push({ path: filePath, source: updated });
647
- }
648
- }
649
-
650
- function mergePlans(plans, originals) {
651
- const filesByPath = new Map();
652
- const deletedPaths = new Set();
653
- const candidates = new Set();
654
- const rules = [];
655
- const warnings = [];
656
- const seenWarnings = new Set();
657
- let convertedRules = 0;
658
- let retainedRules = 0;
659
-
660
- for (const plan of plans) {
661
- for (const file of plan.files) {
662
- if (!originals.has(file.path))
663
- throw new Error(`Planned file is outside the source snapshot: ${file.path}`);
664
- if (filesByPath.has(file.path) || deletedPaths.has(file.path)) {
665
- throw new Error(`Multiple package groups planned changes for ${file.path}`);
666
- }
667
- filesByPath.set(file.path, file);
668
- }
669
- for (const path of plan.deletedFiles) {
670
- if (!originals.has(path))
671
- throw new Error(`Planned deletion is outside the source snapshot: ${path}`);
672
- if (filesByPath.has(path) || deletedPaths.has(path)) {
673
- throw new Error(`Multiple package groups planned changes for ${path}`);
674
- }
675
- deletedPaths.add(path);
676
- }
677
- for (const candidate of plan.candidates) candidates.add(candidate);
678
- convertedRules += plan.convertedRules;
679
- retainedRules += plan.retainedRules;
680
- rules.push(...plan.rules);
681
- // Per-stylesheet planning repeats the same source-site warning once per
682
- // stylesheet; the user-facing report keeps the first of each.
683
- for (const warning of plan.warnings) {
684
- const key = `${warning.code}\0${warning.file}\0${warning.start}\0${warning.end}`;
685
- if (seenWarnings.has(key)) continue;
686
- seenWarnings.add(key);
687
- warnings.push(warning);
688
- }
689
- }
690
- return { filesByPath, deletedPaths, candidates, rules, warnings, convertedRules, retainedRules };
691
- }
692
-
693
- async function findPackageRoot(start) {
694
- let directory = start;
695
- while (true) {
696
- try {
697
- await readFile(join(directory, "package.json"), "utf8");
698
- return directory;
699
- } catch (error) {
700
- if (error.code !== "ENOENT") throw error;
701
- }
702
- const parent = dirname(directory);
703
- if (parent === directory) throw new Error(`No package.json was found from ${start}`);
704
- directory = parent;
705
- }
706
- }
707
-
708
- async function findGitRoot(cwd) {
709
- try {
710
- const { stdout } = await run("git", ["rev-parse", "--show-toplevel"], { cwd });
711
- return resolve(stdout.trim());
712
- } catch {
713
- return undefined;
714
- }
715
- }
716
-
717
- async function isIgnoredByGit(gitRoot, path) {
718
- if (gitRoot === path) return false;
719
- try {
720
- await run("git", ["check-ignore", "-q", "--", relative(gitRoot, path)], { cwd: gitRoot });
721
- return true;
722
- } catch {
723
- return false;
724
- }
725
- }
726
-
727
- async function discoverFiles(root, useGit) {
728
- if (!useGit) return collectFiles(root, isRelevantDiscoveredFile);
729
-
730
- const { stdout } = await run("git", ["ls-files", "-co", "--exclude-standard", "-z", "--", "."], {
731
- cwd: root,
732
- maxBuffer: 64 * 1024 * 1024,
733
- });
734
- const paths = stdout
735
- .split("\0")
736
- .filter(Boolean)
737
- .map((path) => resolve(root, path))
738
- .filter((path) => !hasIgnoredDirectory(root, path) && isRelevantDiscoveredFile(path));
739
- const existing = await Promise.all(
740
- paths.map(async (path) => {
741
- try {
742
- return (await stat(path)).isFile() ? path : undefined;
743
- } catch {
744
- return undefined;
745
- }
746
- }),
747
- );
748
- return existing.filter(Boolean).sort(compareStrings);
749
- }
750
-
751
- async function discoverPackageRoots(workspaceRoot, paths) {
752
- const roots = paths.filter((path) => basename(path) === "package.json").map(dirname);
753
- try {
754
- await readFile(join(workspaceRoot, "package.json"), "utf8");
755
- roots.push(workspaceRoot);
756
- } catch (error) {
757
- if (error.code !== "ENOENT") throw error;
758
- }
759
- return [...new Set(roots)].sort(compareStrings);
760
- }
761
-
762
- function owningPackage(path, packageRoots) {
763
- return packageRoots
764
- .filter((root) => isWithin(root, path))
765
- .sort((left, right) => right.length - left.length)[0];
766
- }
767
-
768
- function isWithin(root, path) {
769
- return path === root || path.startsWith(`${root}${sep}`);
770
- }
771
-
772
- function hasIgnoredDirectory(root, path) {
773
- return relative(root, path)
774
- .split(/[\\/]/)
775
- .some((part) => IGNORED_DIRECTORIES.has(part));
776
- }
777
-
778
- function isRelevantDiscoveredFile(path) {
779
- return (
780
- basename(path) === "package.json" ||
781
- isStylesheetPath(path) ||
782
- SOURCE_EXTENSIONS.has(extension(path))
783
- );
784
- }
785
-
786
- function isStylesheetPath(path) {
787
- return STYLESHEET_SYNTAX.has(extension(path));
788
- }
789
-
790
- function stylesheetSyntax(path) {
791
- return STYLESHEET_SYNTAX.get(extension(path));
792
- }
793
-
794
- function isStylesheetModule(path) {
795
- const syntax = stylesheetSyntax(path);
796
- return syntax !== undefined && path.endsWith(`.module.${syntax}`);
797
- }
798
-
799
- function sourceReferencesStyle(file, stylePath) {
800
- let importPath = normalizedRelativePath(dirname(file.path), stylePath);
801
- if (!importPath.startsWith(".")) importPath = `./${importPath}`;
802
- return [`'${importPath}'`, `"${importPath}"`, `\`${importPath}\``].some((literal) =>
803
- file.source.includes(literal),
804
- );
805
- }
806
-
807
- function isLocalVueReference(reference) {
808
- return (
809
- reference.startsWith(".") ||
810
- reference.startsWith("/") ||
811
- reference.startsWith("@/") ||
812
- reference.startsWith("~/") ||
813
- reference.startsWith("#") ||
814
- reference.includes("/") ||
815
- reference.includes(".vue")
816
- );
817
- }
818
-
819
- // An unresolved local reference only threatens the caller proof when it
820
- // could actually name a Vue file: `.vue` spellings, extensionless paths, and
821
- // aliases. An explicit foreign extension (`./utils.ts`) cannot.
822
- function couldResolveToVue(reference) {
823
- const path = reference.split(/[?#]/, 1)[0];
824
- return path.endsWith(".vue") || extension(path) === "";
825
- }
826
-
827
- function vueReferenceTarget(importer, reference, vuePaths) {
828
- if (!reference.startsWith(".")) return undefined;
829
- const target = resolve(dirname(importer), reference);
830
- return [target, `${target}.vue`, join(target, "index.vue")].find((path) => vuePaths.has(path));
831
- }
832
-
833
- function vueLiteralTargets(file, vuePaths) {
834
- return new Set(
835
- [...file.source.matchAll(/(["'`])([^"'`\r\n]+)\1/g)].flatMap((match) => {
836
- // A local glob can match `.vue` files without spelling the extension
837
- // (`import.meta.glob("./components/*")`), so any glob-bearing local
838
- // pattern conservatively opens every caller surface.
839
- if (
840
- /[?*[\]{}]/.test(match[2]) &&
841
- (match[2].includes("vue") || isLocalVueReference(match[2]))
842
- ) {
843
- return [...vuePaths];
844
- }
845
- const target = vueReferenceTarget(file.path, match[2], vuePaths);
846
- if (target) return [target];
847
- return match[2].includes(".vue") ? [...vuePaths] : [];
848
- }),
849
- );
850
- }
851
-
852
- function normalizedVueTag(value) {
853
- return value.replaceAll("-", "").toLowerCase();
854
- }
855
-
856
- function buildVueComponentGraph(
857
- ownedVue,
858
- sourceFiles,
859
- analyses,
860
- { styleSources, pathOwners, packageRoot },
861
- ) {
862
- const vuePaths = new Set(ownedVue.map((file) => file.path));
863
- const callers = new Map([...vuePaths].map((path) => [path, []]));
864
- const callerOpen = new Set();
865
- // A stylesheet import that does not resolve to a package-owned file (a
866
- // dependency or aliased stylesheet) still loads globally and can shadow
867
- // scoped deletions, but its selectors cannot be analyzed.
868
- let unresolvedStyleImport = false;
869
- const stylesheetReference = /\.(?:css|scss|sass|less)(?:$|[?#])/;
870
-
871
- for (const file of ownedVue) {
872
- const analysis = analyses.get(file.path);
873
- if (analysis.retained) {
874
- for (const target of vuePaths) callerOpen.add(target);
875
- continue;
876
- }
877
- const bindings = analysis.componentImports
878
- .map((entry) => ({
879
- ...entry,
880
- target: vueReferenceTarget(file.path, entry.source, vuePaths),
881
- }))
882
- .filter((entry) => entry.target);
883
- analysis.resolvedComponents = analysis.componentSites.flatMap((site) => {
884
- const matches = bindings.filter(
885
- (entry) => normalizedVueTag(entry.local) === normalizedVueTag(site.tag),
886
- );
887
- if (matches.length !== 1) return [];
888
- const edge = { parent: file.path, child: matches[0].target, site };
889
- callers.get(edge.child).push(edge);
890
- if (site.idAttribute || analysis.dynamic) {
891
- callerOpen.add(edge.child);
892
- analyses.get(edge.child).rootIdsOverridden = true;
893
- }
894
- // A component rendered as a single-root parent's root chains the
895
- // parent's own fallthrough surface down to this child; that chain is
896
- // not modeled, so the child cannot be closed.
897
- if (analysis.alwaysRenderedRoots < 2 && analysis.rootStarts.includes(site.nodeStart)) {
898
- callerOpen.add(edge.child);
899
- }
900
- return [edge];
901
- });
902
- analysis.componentsOpen = analysis.resolvedComponents.length !== analysis.componentSites.length;
903
- analysis.setupImports = new Set(bindings.map((entry) => entry.target));
904
- if (analysis.componentsOpen) {
905
- for (const target of vuePaths) callerOpen.add(target);
906
- }
907
- }
908
-
909
- for (const file of sourceFiles) {
910
- let references;
911
- let blockReferences = [];
912
- if (extension(file.path) === ".vue") {
913
- const fileAnalysis = analyses.get(file.path);
914
- references = fileAnalysis?.scriptStyleImports ?? [];
915
- blockReferences = fileAnalysis?.styleBlockImports?.map((entry) => entry.reference) ?? [];
916
- } else {
917
- try {
918
- references = staticImports(file.path, file.source);
919
- } catch {
920
- references = [];
921
- }
922
- }
923
- if (
924
- /\bimport\s*\(/.test(file.source) ||
925
- references.some(
926
- (reference) =>
927
- !stylesheetReference.test(reference) &&
928
- isLocalVueReference(reference) &&
929
- couldResolveToVue(reference) &&
930
- !vueReferenceTarget(file.path, reference, vuePaths),
931
- )
932
- ) {
933
- for (const target of vuePaths) callerOpen.add(target);
934
- }
935
- const resolvesOwned = (reference) =>
936
- stylesheetReferenceTargets(file.path, reference, styleSources).some(
937
- (path) => pathOwners.get(path) === packageRoot,
938
- );
939
- if (
940
- pathOwners.get(file.path) === packageRoot &&
941
- (references.some(
942
- (reference) =>
943
- stylesheetReference.test(reference) &&
944
- // Bare script specifiers load package CSS this analysis cannot see.
945
- (!reference.startsWith(".") || !resolvesOwned(reference)),
946
- ) ||
947
- blockReferences.some((reference) => !resolvesOwned(reference)))
948
- ) {
949
- unresolvedStyleImport = true;
950
- }
951
- const imported = new Set(
952
- references.flatMap((reference) => {
953
- const target = vueReferenceTarget(file.path, reference, vuePaths);
954
- return target ? [target] : [];
955
- }),
956
- );
957
- for (const target of imported) {
958
- if (extension(file.path) !== ".vue" || !analyses.get(file.path)?.setupImports?.has(target)) {
959
- callerOpen.add(target);
960
- }
961
- }
962
- for (const target of vueLiteralTargets(file, vuePaths)) {
963
- if (!imported.has(target)) callerOpen.add(target);
964
- }
965
- }
966
-
967
- return { callers, callerOpen, unresolvedStyleImport };
968
- }
969
-
970
- // Vue analysis can produce retention warnings even when nothing remains to
971
- // plan (all blocks unsupported, unsupported Vue version); surface them
972
- // through an otherwise empty plan instead of dropping them.
973
- function vueWarningsOnlyResult(preparedVue) {
974
- if (preparedVue.warnings.length === 0) return {};
975
- return {
976
- plan: {
977
- files: [],
978
- deletedFiles: [],
979
- unlinkedFiles: [],
980
- candidates: [],
981
- rules: [],
982
- warnings: preparedVue.warnings,
983
- convertedRules: 0,
984
- retainedRules: 0,
985
- },
986
- };
987
- }
988
-
989
- // Lower this package's own Vue SFCs into their planner dual identity: one
990
- // stylesheet entry per SFC with migratable plain-CSS scoped blocks, plus a
991
- // consumer entry carrying the template's literal class sites. Files whose
992
- // blocks or templates cannot be analyzed keep their raw source and warn.
993
- async function preparePackageVue({
994
- packageRoot,
995
- sourceFiles,
996
- styleSources,
997
- pathOwners,
998
- targetable,
999
- explicitStyle,
1000
- snapshots,
1001
- workspaceRoot,
1002
- workspaces,
1003
- scannedPaths,
1004
- }) {
1005
- const none = {
1006
- files: new Map(),
1007
- stylesheets: [],
1008
- stylePaths: new Set(),
1009
- unscopedPaths: new Set(),
1010
- warnings: [],
1011
- compiler: undefined,
1012
- };
1013
- // An explicit non-vue stylesheet selection plans only that stylesheet.
1014
- if (explicitStyle && extension(explicitStyle) !== ".vue") return none;
1015
- // Ignore filtering scopes what gets migrated, never what gets scanned: a
1016
- // gitignored SFC's retained style blocks still shadow scoped deletions.
1017
- const ownedVue = sourceFiles.filter(
1018
- (file) => extension(file.path) === ".vue" && pathOwners.get(file.path) === packageRoot,
1019
- );
1020
- const selected = ownedVue.filter(
1021
- (file) => targetable.has(file.path) && (!explicitStyle || file.path === explicitStyle),
1022
- );
1023
- if (selected.length === 0) return none;
1024
-
1025
- const loaded = await loadProjectVueCompiler(packageRoot);
1026
- const warnings = [];
1027
- if (loaded.unsupportedVersion) {
1028
- for (const file of selected) {
1029
- warnings.push({
1030
- code: "unsupported-vue-version",
1031
- file: file.path,
1032
- start: 0,
1033
- end: 0,
1034
- message: `Vue ${loaded.unsupportedVersion} is not supported; only Vue 3 SFCs are analyzed.`,
1035
- });
1036
- }
1037
- return { ...none, warnings };
1038
- }
1039
-
1040
- // Every owned SFC is analyzed even under explicit selection: retained
1041
- // style blocks anywhere in the package feed the cascade-shadow gate below.
1042
- const analyses = new Map(
1043
- ownedVue.map((file) => [file.path, analyzeVueSource(loaded.compiler, file.path, file.source)]),
1044
- );
1045
- const selectedPaths = new Set(selected.map((file) => file.path));
1046
- for (const file of ownedVue) {
1047
- const analysis = analyses.get(file.path);
1048
- if (analysis.retained) continue;
1049
- for (const edge of analysis.styleBlockImports) {
1050
- const local = stylesheetReferenceTargets(file.path, edge.reference, styleSources).some(
1051
- (path) => pathOwners.get(path) === packageRoot,
1052
- );
1053
- if (local) continue;
1054
- analysis.escapeUnverifiable = true;
1055
- if (selectedPaths.has(file.path)) {
1056
- warnings.push({
1057
- code: "unsupported-sfc-block",
1058
- file: file.path,
1059
- start: edge.start,
1060
- end: edge.end,
1061
- message: `The external style ${JSON.stringify(edge.reference)} could not be resolved inside the package.`,
1062
- });
1063
- }
1064
- }
1065
- }
1066
- let sass;
1067
- let less;
1068
- const compileBlocks = async (file, blocks) => {
1069
- for (const block of blocks.filter((block) => block.syntax !== "css")) {
1070
- const virtualPath = `${file.path}.${block.syntax}`;
1071
- const compiled = isSassPath(virtualPath)
1072
- ? await compileSassEntry(
1073
- (sass ??= await loadProjectSass(packageRoot)),
1074
- virtualPath,
1075
- block.content,
1076
- { virtualEntry: true },
1077
- )
1078
- : await compileLessEntry(
1079
- (less ??= await loadProjectLess(packageRoot)),
1080
- virtualPath,
1081
- block.content,
1082
- );
1083
- validateCss(compiled.css);
1084
- for (const loadedPath of compiled.loadedPaths) {
1085
- if (loadedPath === virtualPath || !isProjectInput(workspaceRoot, loadedPath)) continue;
1086
- const source = await snapshotLoadedSource(snapshots, loadedPath);
1087
- if (!styleSources.has(loadedPath)) styleSources.set(loadedPath, source);
1088
- if (!pathOwners.has(loadedPath)) pathOwners.set(loadedPath, packageRoot);
1089
- }
1090
- block.analysisSource = compiled.css;
1091
- block.sourcePath = virtualPath;
1092
- block.sourceMappings = compiled.sourceMappings;
1093
- }
1094
- };
1095
- for (const file of selected) {
1096
- const analysis = analyses.get(file.path);
1097
- if (!analysis.retained) {
1098
- await compileBlocks(file, [...analysis.blocks, ...analysis.moduleBlocks]);
1099
- }
1100
- }
1101
- const vueGraph = buildVueComponentGraph(ownedVue, sourceFiles, analyses, {
1102
- styleSources,
1103
- pathOwners,
1104
- packageRoot,
1105
- });
1106
-
1107
- // Non-scoped CSS in the package is unlayered and can outrank the layered
1108
- // utilities that replace a deleted scoped rule. The planner retains any
1109
- // scoped rule whose classes this pool also targets, so collect every
1110
- // package stylesheet, HTML source (inline style blocks are never parsed),
1111
- // and retained Vue style block.
1112
- // The shadow corpus is a list of parseable CSS pieces whose selector
1113
- // surface the planner indexes; anything whose selectors cannot be proven
1114
- // (interpolation or `&`-concatenation in preprocessor text, inline HTML
1115
- // style blocks, unanalyzable SFCs, unextractable escapes) marks the whole
1116
- // corpus unverifiable and retains every closed deletion.
1117
- const generatesSelectors = (text) => /#\{|@\{|&[\w-]/.test(text);
1118
- const vueShadowCss = [];
1119
- const vueShadowModuleCss = [];
1120
- let vueShadowUnverifiable = vueGraph.unresolvedStyleImport;
1121
- for (const [path, source] of styleSources) {
1122
- if (pathOwners.get(path) !== packageRoot) continue;
1123
- if (isPreprocessorPath(path) && generatesSelectors(source)) {
1124
- vueShadowUnverifiable = true;
1125
- continue;
1126
- }
1127
- // Module class and id names are localized at build time; the planner
1128
- // indexes only their global (type/attribute/:global) selector surface.
1129
- if (isStylesheetModule(path)) vueShadowModuleCss.push(source);
1130
- else vueShadowCss.push({ path, source });
1131
- }
1132
- for (const file of sourceFiles) {
1133
- if (
1134
- extension(file.path) === ".html" &&
1135
- pathOwners.get(file.path) === packageRoot &&
1136
- /<style/i.test(file.source)
1137
- ) {
1138
- vueShadowUnverifiable = true;
1139
- }
1140
- }
1141
- for (const file of ownedVue) {
1142
- const analysis = analyses.get(file.path);
1143
- if (analysis.retained) {
1144
- if (/<style/i.test(file.source)) vueShadowUnverifiable = true;
1145
- continue;
1146
- }
1147
- if (analysis.escapeUnverifiable || analysis.scriptImportsUnverifiable) {
1148
- vueShadowUnverifiable = true;
1149
- }
1150
- for (const text of analysis.shadowPreprocessorTexts) {
1151
- if (generatesSelectors(text)) vueShadowUnverifiable = true;
1152
- else vueShadowCss.push({ path: file.path, source: text });
1153
- }
1154
- for (const text of analysis.unscopedShadowPreprocessorTexts) {
1155
- if (generatesSelectors(text)) vueShadowUnverifiable = true;
1156
- else {
1157
- vueShadowCss.push({ path: file.path, source: text, migratingUnscoped: true });
1158
- }
1159
- }
1160
- vueShadowCss.push(
1161
- ...analysis.shadowCssTexts.map((source) => ({ path: file.path, source })),
1162
- ...analysis.unscopedShadowCssTexts.map((source) => ({
1163
- path: file.path,
1164
- source,
1165
- migratingUnscoped: true,
1166
- })),
1167
- ...analysis.blocks.map((block) => ({
1168
- path: file.path,
1169
- source: block.analysisSource ?? block.content,
1170
- scoped: true,
1171
- })),
1172
- );
1173
- vueShadowModuleCss.push(
1174
- ...analysis.shadowModuleCssTexts,
1175
- ...analysis.moduleBlocks.map((block) => block.analysisSource ?? block.content),
1176
- );
1177
- }
1178
-
1179
- const packageIsPrivate =
1180
- JSON.parse(await snapshotFile(snapshots, join(packageRoot, "package.json"))).private === true;
1181
- const files = new Map();
1182
- const stylesheets = [];
1183
- const stylePaths = new Set();
1184
- const unscopedPaths = new Set();
1185
- const ownedSources = sourceFiles.filter((file) => pathOwners.get(file.path) === packageRoot);
1186
- // A scanned HTML file without a stylesheet link never enters sourceFiles,
1187
- // but its class attributes are still a global usage surface (e.g. a
1188
- // gitignored mount shell), so its existence alone defeats the proof.
1189
- const sourcePathSet = new Set(sourceFiles.map((file) => file.path));
1190
- const hiddenHtml = scannedPaths.some(
1191
- (path) =>
1192
- extension(path) === ".html" &&
1193
- pathOwners.get(path) === packageRoot &&
1194
- !sourcePathSet.has(path),
1195
- );
1196
- const projectWideUsageProven =
1197
- packageIsPrivate &&
1198
- workspaceRoot === packageRoot &&
1199
- !workspaces &&
1200
- !explicitStyle &&
1201
- !hiddenHtml &&
1202
- ownedSources.every((file) => targetable.has(file.path)) &&
1203
- ownedVue.every((file) => !analyses.get(file.path).retained);
1204
- const elementsByFile = new Map();
1205
- const addElement = (path, element, cssPaths) => {
1206
- if ((!element.classAttribute && !element.moduleBinding) || cssPaths.length === 0) return;
1207
- const elements = elementsByFile.get(path) ?? [];
1208
- elements.push({ ...element, cssPaths: [...new Set(cssPaths)] });
1209
- elementsByFile.set(path, elements);
1210
- };
1211
- const elementClasses = (element) =>
1212
- element.matchClasses ?? element.classAttribute?.value.split(/\s+/).filter(Boolean) ?? [];
1213
- const componentRootSite = (site, root) => ({
1214
- ...site,
1215
- matchClasses: [...elementClasses(site), ...elementClasses(root)],
1216
- matchIds: [site.idAttribute?.value ?? root.idAttribute?.value].filter(Boolean),
1217
- matchTag: root.tag,
1218
- });
1219
-
1220
- for (const file of selected) {
1221
- const analysis = analyses.get(file.path);
1222
- if (analysis.retained) continue;
1223
- const importedStyles = new Set(
1224
- [
1225
- // A bare script specifier is a package import; resolving it against
1226
- // the SFC directory could bind an unrelated local file.
1227
- ...analysis.scriptStyleImports
1228
- .filter(
1229
- (reference) =>
1230
- reference.startsWith(".") &&
1231
- STYLESHEET_SYNTAX.has(extension(reference.split(/[?#]/, 1)[0])),
1232
- )
1233
- .flatMap((reference) => stylesheetReferenceTargets(file.path, reference, styleSources)),
1234
- ...analysis.styleBlockImports.flatMap((entry) =>
1235
- stylesheetReferenceTargets(file.path, entry.reference, styleSources),
1236
- ),
1237
- ].filter((path) => pathOwners.get(path) === packageRoot),
1238
- );
1239
- for (const path of importedStyles) {
1240
- stylePaths.add(path);
1241
- if (extension(path) !== ".css") continue;
1242
- for (const imported of cssImports(styleSources.get(path))) {
1243
- // Conditional imports need variant-aware contexts; retaining them is
1244
- // safer than attaching an unconditional utility.
1245
- if (imported.media) continue;
1246
- for (const target of stylesheetReferenceTargets(path, imported.href, styleSources)) {
1247
- if (pathOwners.get(target) === packageRoot) importedStyles.add(target);
1248
- }
1249
- }
1250
- }
1251
- const ownContexts = [
1252
- ...(analysis.blocks.length > 0 ||
1253
- analysis.unscopedBlocks.length > 0 ||
1254
- (analysis.moduleBlocks.length > 0 && !analysis.moduleClosureBroken)
1255
- ? [file.path]
1256
- : []),
1257
- ...[...importedStyles].filter((path) => !isStylesheetModule(path)),
1258
- ];
1259
- for (const element of analysis.htmlElements) {
1260
- const ownElement =
1261
- analysis.rootIdsOverridden && analysis.rootStarts.includes(element.nodeStart)
1262
- ? { ...element, matchIds: [] }
1263
- : element;
1264
- addElement(file.path, ownElement, ownContexts);
1265
- }
1266
- }
1267
-
1268
- for (const parent of ownedVue) {
1269
- const analysis = analyses.get(parent.path);
1270
- if (analysis.retained) continue;
1271
- for (const edge of analysis.resolvedComponents) {
1272
- const childAnalysis = analyses.get(edge.child);
1273
- // Vue only inherits call-site attributes onto a single-root child, so
1274
- // the total root count must gate the rewrite independently of writable sites.
1275
- const singleRoot =
1276
- childAnalysis?.rootStarts.length === 1 &&
1277
- !childAnalysis.rootVFor &&
1278
- !childAnalysis.rootFragment;
1279
- const childRoots = childAnalysis?.htmlElements.filter((element) =>
1280
- childAnalysis.rootStarts.includes(element.nodeStart),
1281
- );
1282
- if (
1283
- selectedPaths.has(parent.path) &&
1284
- singleRoot &&
1285
- childRoots?.length === 1 &&
1286
- !childAnalysis.fallthroughUnverifiable
1287
- ) {
1288
- addElement(parent.path, componentRootSite(edge.site, childRoots[0]), [parent.path]);
1289
- }
1290
- if (
1291
- selectedPaths.has(edge.child) &&
1292
- singleRoot &&
1293
- // A self-recursive edge is already covered by the parent-style site
1294
- // above; adding the same span twice would produce overlapping edits.
1295
- edge.parent !== edge.child &&
1296
- !childAnalysis?.fallthroughUnverifiable
1297
- ) {
1298
- addElement(parent.path, edge.site, [edge.child]);
1299
- if (childRoots?.length === 1) {
1300
- // The caller's classes land on the child root, so module planning
1301
- // must see them next to the root's binding; the read-only class
1302
- // attribute marks the record as shadow-only (never rewritten, never
1303
- // counted as a reference).
1304
- const shadowSite = {
1305
- ...componentRootSite(edge.site, childRoots[0]),
1306
- moduleBinding: childRoots[0].moduleBinding,
1307
- };
1308
- if (shadowSite.classAttribute) {
1309
- shadowSite.classAttribute = { ...shadowSite.classAttribute, writable: false };
1310
- addElement(parent.path, shadowSite, [edge.child]);
1311
- }
1312
- }
1313
- }
1314
- }
1315
- }
1316
-
1317
- for (const file of selected) {
1318
- await rejectSymlinkTarget(file.path, packageRoot);
1319
- const analysis = analyses.get(file.path);
1320
- warnings.push(...analysis.warnings);
1321
- if (analysis.retained) continue;
1322
- const componentOpen =
1323
- analysis.componentsOpen ||
1324
- analysis.resolvedComponents.some((edge) => {
1325
- const child = analyses.get(edge.child);
1326
- return (
1327
- !child ||
1328
- child.retained ||
1329
- child.dynamic ||
1330
- child.fallthroughUnverifiable ||
1331
- child.rootVFor ||
1332
- child.rootFragment ||
1333
- child.rootStarts.length !== 1 ||
1334
- !child.htmlElements.some((element) => element.nodeStart === child.rootStarts[0])
1335
- );
1336
- });
1337
- const callers = vueGraph.callers.get(file.path);
1338
- const callerOpen =
1339
- !packageIsPrivate ||
1340
- callers.length === 0 ||
1341
- vueGraph.callerOpen.has(file.path) ||
1342
- callers.some(
1343
- (edge) => analyses.get(edge.parent)?.dynamic || analyses.get(edge.parent)?.retained,
1344
- );
1345
- const retention = analysis.dynamic
1346
- ? "dynamic-template-class"
1347
- : componentOpen
1348
- ? "component-class-target"
1349
- : analysis.alwaysRenderedRoots < 2 && callerOpen
1350
- ? "open-root-fallthrough"
1351
- : undefined;
1352
- // ponytail: unscoped deletion is limited to private single-source packages;
1353
- // widen it only when the dependency graph can prove stylesheet co-loading.
1354
- const migrateUnscoped =
1355
- analysis.blocks.length === 0 &&
1356
- analysis.unscopedBlocks.length > 0 &&
1357
- projectWideUsageProven &&
1358
- !analysis.dynamic &&
1359
- // Injected and slotted content can use any class an unscoped rule
1360
- // targets without ever receiving its utility.
1361
- !analysis.vHtml &&
1362
- !analysis.hasSlot &&
1363
- !analysis.escapeUnverifiable &&
1364
- !componentOpen &&
1365
- ownedSources.length === 1 &&
1366
- ownedSources[0].path === file.path;
1367
- if (migrateUnscoped) {
1368
- await compileBlocks(file, analysis.unscopedBlocks);
1369
- unscopedPaths.add(file.path);
1370
- }
1371
- if (analysis.unscopedBlocks.length > 0 && !migrateUnscoped) {
1372
- for (const block of analysis.unscopedBlocks) {
1373
- warnings.push({
1374
- code: "unscoped-style-block",
1375
- file: file.path,
1376
- start: block.contentStart,
1377
- end: block.contentEnd,
1378
- message: "The global reach of this unscoped style block could not be proven.",
1379
- });
1380
- }
1381
- }
1382
- // Module classes are hashed, so corpus CSS cannot target them directly --
1383
- // but unknown classes can still land on a binding element (a dynamic
1384
- // surface anywhere in the template, or parent fallthrough onto a single
1385
- // root) and compete for the same properties, so those surfaces must be
1386
- // closed exactly like the scoped ones. `componentOpen` is exempt: a child
1387
- // component's root can never carry the hashed class, because a `$style`
1388
- // binding on a component tag is already an opaque (dynamic) surface.
1389
- const moduleRetention = analysis.dynamic
1390
- ? "dynamic-template-class"
1391
- : analysis.alwaysRenderedRoots < 2 && callerOpen
1392
- ? "open-root-fallthrough"
1393
- : undefined;
1394
- const migrateModule =
1395
- analysis.moduleBlocks.length > 0 && !analysis.moduleClosureBroken && !moduleRetention;
1396
- if (analysis.moduleBlocks.length > 0 && !migrateModule) {
1397
- const [code, message] = analysis.moduleClosureBroken
1398
- ? [
1399
- "unsupported-css-module-reference",
1400
- "`$style` is referenced outside provable direct member accesses, so the module is retained.",
1401
- ]
1402
- : moduleRetention === "dynamic-template-class"
1403
- ? [
1404
- "dynamic-template-class",
1405
- "A dynamic class binding makes the template's class set unprovable, so the module is retained.",
1406
- ]
1407
- : [
1408
- "open-root-fallthrough",
1409
- "A parent component can merge classes onto the single root element, so the module is retained.",
1410
- ];
1411
- for (const block of analysis.moduleBlocks) {
1412
- warnings.push({
1413
- code,
1414
- file: file.path,
1415
- start: block.contentStart,
1416
- end: block.contentEnd,
1417
- message,
1418
- });
1419
- }
1420
- }
1421
- // Module blocks were already compiled alongside the scoped blocks for
1422
- // the shadow corpus; recompiling here would double the preprocessor work.
1423
- const vueBlocks = migrateUnscoped ? analysis.unscopedBlocks : analysis.blocks;
1424
- if (vueBlocks.length === 0 && !migrateModule) continue;
1425
- const vueRetention = migrateUnscoped ? undefined : retention;
1426
- // Same-file scoped blocks stay out of the general corpus; the batch planner
1427
- // adds back only their retained selectors when it plans the module entry.
1428
- const shadowCss = vueShadowCss
1429
- .filter(
1430
- (entry) =>
1431
- entry.path !== file.path ||
1432
- (!entry.scoped && !(migrateUnscoped && entry.migratingUnscoped)),
1433
- )
1434
- .map((entry) => entry.source);
1435
- if (migrateModule) {
1436
- // Module classes are hashed, so this entry is always a closed world;
1437
- // its own block text remains in the module shadow channel, which can
1438
- // only over-retain. It plans before the scoped entry so scoped
1439
- // planning can see which `$style` bindings survived: a live binding
1440
- // is an opaque cascade surface (its retained module rule competes
1441
- // unlayered with a replacement utility).
1442
- stylesheets.push({
1443
- cssPath: file.path,
1444
- cssSource: file.source,
1445
- cssModuleId: normalizedRelativePath(packageRoot, file.path),
1446
- syntax: "css",
1447
- isModule: true,
1448
- vueBlocks: analysis.moduleBlocks,
1449
- vueModule: true,
1450
- vueShadowCss: shadowCss,
1451
- vueShadowModuleCss,
1452
- vueShadowUnverifiable,
1453
- });
1454
- }
1455
- if (vueBlocks.length > 0) {
1456
- stylesheets.push({
1457
- cssPath: file.path,
1458
- cssSource: file.source,
1459
- cssModuleId: normalizedRelativePath(packageRoot, file.path),
1460
- syntax: "css",
1461
- isModule: !vueRetention,
1462
- vueBlocks,
1463
- vueRetention,
1464
- vueUnscoped: migrateUnscoped,
1465
- vueShadowCss: vueRetention ? undefined : shadowCss,
1466
- vueShadowModuleCss: vueRetention ? undefined : vueShadowModuleCss,
1467
- vueShadowUnverifiable: vueRetention ? undefined : vueShadowUnverifiable,
1468
- });
1469
- }
1470
- }
1471
-
1472
- for (const file of ownedVue) {
1473
- const analysis = analyses.get(file.path);
1474
- if (analysis.retained) continue;
1475
- const elements = elementsByFile.get(file.path) ?? [];
1476
- const contextPaths = [...new Set(elements.flatMap((element) => element.cssPaths))];
1477
- if (contextPaths.length === 0) continue;
1478
- await rejectSymlinkTarget(file.path, packageRoot);
1479
- files.set(file.path, {
1480
- ...file,
1481
- htmlElements: elements,
1482
- htmlStylesheets: contextPaths.map((cssPath) => ({
1483
- cssPath,
1484
- variants: [],
1485
- direct: cssPath === file.path,
1486
- analyzable: true,
1487
- })),
1488
- htmlReferencesSafe: !analysis.dynamic,
1489
- htmlScriptText: analysis.scriptText,
1490
- });
1491
- }
1492
- return {
1493
- files,
1494
- stylesheets,
1495
- stylePaths,
1496
- unscopedPaths,
1497
- warnings,
1498
- compiler: loaded.compiler,
1499
- sass,
1500
- less,
1501
- };
1502
- }
1503
-
1504
- async function preparePackageHtml({
1505
- packageRoot,
1506
- sourceFiles,
1507
- styleSources,
1508
- snapshots,
1509
- pathOwners,
1510
- styleDependents,
1511
- }) {
1512
- const files = [];
1513
- const stylePaths = new Set();
1514
- const generatedPaths = new Set();
1515
- const removableLinks = [];
1516
- const warnings = [];
1517
-
1518
- const htmlFiles = sourceFiles.filter((file) => extension(file.path) === ".html");
1519
- // Package-owned HTML goes first so stylesheets it discovers are claimed for
1520
- // this package before foreign consumers are matched against that ownership.
1521
- const orderedFiles = [
1522
- ...htmlFiles.filter((file) => pathOwners.get(file.path) === packageRoot),
1523
- ...htmlFiles.filter((file) => pathOwners.get(file.path) !== packageRoot),
1524
- ];
1525
- for (const file of orderedFiles) {
1526
- const owner = pathOwners.get(file.path);
1527
- // Foreign HTML is analyzed only as a consumer of this package's
1528
- // stylesheets; its other links and attributes are its own package's
1529
- // concern, so they never warn here.
1530
- const foreign = owner !== packageRoot;
1531
- if (foreign && !owner) continue;
1532
- const referenceRoot = foreign ? owner : packageRoot;
1533
- const parsed = parseHtmlSource(file.path, file.source);
1534
- const contexts = [];
1535
- let linkBase = dirname(file.path);
1536
- const base = parsed.bases[0];
1537
- if (base) {
1538
- const baseReference = base.href.split(/[?#]/, 1)[0];
1539
- const basePath =
1540
- base.writable &&
1541
- (baseReference === ""
1542
- ? file.path
1543
- : localHtmlReference(referenceRoot, dirname(file.path), base.href));
1544
- if (!basePath || !isWithin(referenceRoot, basePath)) {
1545
- if (!foreign) {
1546
- warnings.push(
1547
- htmlWarning(
1548
- "unsupported-html-base",
1549
- file.path,
1550
- base.start,
1551
- base.end,
1552
- "A remote or unrepresentable base URL prevents safe stylesheet link resolution.",
1553
- ),
1554
- );
1555
- }
1556
- linkBase = undefined;
1557
- } else {
1558
- linkBase = base.href.split(/[?#]/, 1)[0].endsWith("/") ? basePath : dirname(basePath);
1559
- }
1560
- }
1561
- for (const link of parsed.links) {
1562
- const linkedPath = linkBase && localHtmlReference(referenceRoot, linkBase, link.href);
1563
- if (
1564
- !linkedPath ||
1565
- !isWithin(packageRoot, linkedPath) ||
1566
- (foreign && pathOwners.get(linkedPath) !== packageRoot)
1567
- ) {
1568
- if (!foreign) {
1569
- warnings.push(
1570
- htmlWarning(
1571
- "unsupported-html-stylesheet-link",
1572
- file.path,
1573
- link.start,
1574
- link.end,
1575
- "Only local package stylesheet links are analyzed.",
1576
- ),
1577
- );
1578
- }
1579
- continue;
1580
- }
1581
- const variants = mediaVariants(link.media);
1582
- if (variants === undefined) {
1583
- const cssPath =
1584
- (foreign
1585
- ? undefined
1586
- : inferredPreprocessorPath({
1587
- path: linkedPath,
1588
- packageRoot,
1589
- styleSources,
1590
- pathOwners,
1591
- styleDependents,
1592
- })) ?? linkedPath;
1593
- contexts.push({ cssPath, variants: [], direct: true, analyzable: false });
1594
- if (!foreign) {
1595
- warnings.push(
1596
- htmlWarning(
1597
- "unsupported-link-media",
1598
- file.path,
1599
- link.start,
1600
- link.end,
1601
- `The stylesheet link media condition ${JSON.stringify(link.media)} cannot be represented safely.`,
1602
- ),
1603
- );
1604
- }
1605
- continue;
1606
- }
1607
- const contextStart = contexts.length;
1608
- await collectHtmlStyleContexts({
1609
- path: linkedPath,
1610
- variants,
1611
- direct: true,
1612
- packageRoot,
1613
- sourceFiles,
1614
- styleSources,
1615
- snapshots,
1616
- pathOwners,
1617
- styleDependents,
1618
- stylePaths,
1619
- generatedPaths,
1620
- contexts,
1621
- warnings,
1622
- visited: new Set(),
1623
- });
1624
- const directContext = contexts[contextStart];
1625
- if (directContext?.direct) {
1626
- removableLinks.push({
1627
- filePath: file.path,
1628
- cssPath: directContext.cssPath,
1629
- href: link.href,
1630
- media: link.media,
1631
- });
1632
- }
1633
- }
1634
-
1635
- if (foreign && contexts.length === 0) continue;
1636
- if (!foreign && contexts.length > 0) {
1637
- for (const attribute of parsed.dynamicAttributes) {
1638
- warnings.push(
1639
- htmlWarning(
1640
- "dynamic-html-attribute",
1641
- file.path,
1642
- attribute.start,
1643
- attribute.end,
1644
- "This HTML attribute is not a safely writable quoted literal.",
1645
- ),
1646
- );
1647
- }
1648
- }
1649
- files.push({
1650
- ...file,
1651
- htmlElements: parsed.elements,
1652
- htmlStylesheets: deduplicateHtmlContexts(contexts),
1653
- htmlReferencesSafe: parsed.dynamicAttributes.length === 0,
1654
- htmlScriptText: parsed.scriptText,
1655
- });
1656
- }
1657
-
1658
- return { files, stylePaths, generatedPaths, removableLinks, warnings };
1659
- }
1660
-
1661
- async function collectHtmlStyleContexts(state) {
1662
- const key = `${state.path}\0${state.variants.join(":")}`;
1663
- if (state.visited.has(key)) return;
1664
- state.visited.add(key);
1665
- if (!isStylesheetPath(state.path)) return;
1666
- const owner = state.pathOwners.get(state.path);
1667
- if (owner && owner !== state.packageRoot) {
1668
- state.warnings.push(
1669
- htmlWarning(
1670
- "cross-package-stylesheet-link",
1671
- state.path,
1672
- 0,
1673
- 0,
1674
- "A stylesheet owned by another package is not analyzed outside workspace mode.",
1675
- ),
1676
- );
1677
- return;
1678
- }
1679
-
1680
- let source;
1681
- try {
1682
- source =
1683
- state.styleSources.get(state.path) ?? (await snapshotFile(state.snapshots, state.path));
1684
- } catch (error) {
1685
- if (error.code === "ENOENT") {
1686
- if (extension(state.path) === ".css") addInferredPreprocessorContext(state);
1687
- return;
1688
- }
1689
- throw error;
1690
- }
1691
- if (!state.styleSources.has(state.path)) state.styleSources.set(state.path, source);
1692
- if (!owner) state.pathOwners.set(state.path, state.packageRoot);
1693
-
1694
- if (extension(state.path) === ".css" && addInferredPreprocessorContext(state)) return;
1695
-
1696
- state.stylePaths.add(state.path);
1697
- state.contexts.push({
1698
- cssPath: state.path,
1699
- variants: state.variants,
1700
- direct: state.direct,
1701
- analyzable: true,
1702
- });
1703
- if (extension(state.path) !== ".css") return;
1704
-
1705
- for (const imported of cssImports(source)) {
1706
- const variants = mediaVariants(imported.media);
1707
- if (variants === undefined) {
1708
- state.warnings.push(
1709
- htmlWarning(
1710
- "unsupported-link-media",
1711
- state.path,
1712
- imported.start,
1713
- imported.end,
1714
- `The stylesheet import media condition ${JSON.stringify(imported.media)} cannot be represented safely.`,
1715
- ),
1716
- );
1717
- continue;
1718
- }
1719
- const importedPath = localHtmlReference(state.packageRoot, dirname(state.path), imported.href);
1720
- if (!importedPath || !isWithin(state.packageRoot, importedPath)) continue;
1721
- // Link-discovered stylesheets never went through indexStylesheetDependents,
1722
- // so record their import edges here or deletion could leave this importer
1723
- // pointing at a removed module.
1724
- if (
1725
- importedPath !== state.path &&
1726
- (isStylesheetModule(importedPath) || isPreprocessorPath(importedPath))
1727
- ) {
1728
- addStyleDependent(state.styleDependents, importedPath, state.path);
1729
- }
1730
- await collectHtmlStyleContexts({
1731
- ...state,
1732
- path: importedPath,
1733
- // Deduplicate so a cyclic import chain cannot grow the variant list and
1734
- // mint a fresh visited key on every lap.
1735
- variants: [...new Set([...state.variants, ...variants])],
1736
- direct: false,
1737
- });
1738
- }
1739
- }
1740
-
1741
- function inferredPreprocessorPath(state) {
1742
- const stem = basename(state.path, ".css");
1743
- const matches = [...state.styleSources.keys()].filter(
1744
- (path) =>
1745
- isPreprocessorPath(path) &&
1746
- state.pathOwners.get(path) === state.packageRoot &&
1747
- !basename(path).startsWith("_") &&
1748
- !state.styleDependents.has(path) &&
1749
- basename(path, extension(path)) === stem,
1750
- );
1751
- return matches.length === 1 ? matches[0] : undefined;
1752
- }
1753
-
1754
- function addInferredPreprocessorContext(state) {
1755
- // A source importing the generated CSS pins the artifact itself: excluding
1756
- // it from planning while migrating the inferred entry could delete the only
1757
- // source able to rebuild the file that import depends on.
1758
- if (
1759
- state.styleSources.has(state.path) &&
1760
- state.sourceFiles.some(
1761
- (file) => extension(file.path) !== ".html" && sourceReferencesStyle(file, state.path),
1762
- )
1763
- ) {
1764
- return false;
1765
- }
1766
- const path = inferredPreprocessorPath(state);
1767
- if (!path) return false;
1768
- state.generatedPaths.add(state.path);
1769
- state.stylePaths.add(path);
1770
- state.contexts.push({
1771
- cssPath: path,
1772
- variants: state.variants,
1773
- direct: state.direct,
1774
- analyzable: true,
1775
- });
1776
- state.warnings.push(
1777
- htmlWarning(
1778
- "inferred-preprocessor-source",
1779
- state.path,
1780
- 0,
1781
- 0,
1782
- `The linked CSS was matched to the unique preprocessor filename ${basename(path)}.`,
1783
- ),
1784
- );
1785
- return true;
1786
- }
1787
-
1788
- function cssImports(source) {
1789
- const imports = [];
1790
- const masked = maskCssComments(source);
1791
- let depth = 0;
1792
- let quote;
1793
- // Browsers ignore @import once any block rule has appeared, so imports
1794
- // after the first top-level `{` never load and must not be traversed.
1795
- let importsAllowed = true;
1796
- for (let index = 0; index < masked.length; index += 1) {
1797
- const character = masked[index];
1798
- if (quote) {
1799
- if (character === "\\") index += 1;
1800
- else if (character === quote) quote = undefined;
1801
- continue;
1802
- }
1803
- if (character === '"' || character === "'") {
1804
- quote = character;
1805
- continue;
1806
- }
1807
- if (character === "{") {
1808
- depth += 1;
1809
- importsAllowed = false;
1810
- continue;
1811
- }
1812
- if (character === "}") {
1813
- depth = Math.max(0, depth - 1);
1814
- continue;
1815
- }
1816
- if (
1817
- !importsAllowed ||
1818
- depth !== 0 ||
1819
- masked.slice(index, index + 7).toLowerCase() !== "@import" ||
1820
- /[-\w]/.test(masked[index + 7] ?? "")
1821
- )
1822
- continue;
1823
-
1824
- let end = index + 7;
1825
- let importQuote;
1826
- let parentheses = 0;
1827
- for (; end < masked.length; end += 1) {
1828
- const next = masked[end];
1829
- if (importQuote) {
1830
- if (next === "\\") end += 1;
1831
- else if (next === importQuote) importQuote = undefined;
1832
- } else if (next === '"' || next === "'") importQuote = next;
1833
- else if (next === "(") parentheses += 1;
1834
- else if (next === ")") parentheses = Math.max(0, parentheses - 1);
1835
- else if (next === ";" && parentheses === 0) break;
1836
- }
1837
- if (end >= masked.length) continue;
1838
- const statement = masked.slice(index, end + 1);
1839
- const match =
1840
- /^@import\s+(?:url\(\s*)?(?:["']([^"']+)["']|([^"'()\s;]+))\s*\)?\s*([^;]*);$/i.exec(
1841
- statement,
1842
- );
1843
- if (match)
1844
- imports.push({
1845
- href: match[1] ?? match[2],
1846
- media: match[3].trim(),
1847
- start: index,
1848
- end: end + 1,
1849
- });
1850
- index = end;
1851
- }
1852
- // Offsets are collected as string indices and converted together, so the
1853
- // source prefix is scanned once rather than twice per import.
1854
- const offsets = utf8OffsetMap(
1855
- source,
1856
- imports.flatMap((imported) => [imported.start, imported.end]),
1857
- );
1858
- return imports.map((imported) => ({
1859
- ...imported,
1860
- start: offsets.get(imported.start),
1861
- end: offsets.get(imported.end),
1862
- }));
1863
- }
1864
-
1865
- function localHtmlReference(packageRoot, base, reference) {
1866
- const path = reference.split(/[?#]/, 1)[0];
1867
- if (!path || path.startsWith("//") || /^[a-z][a-z\d+.-]*:/i.test(path)) return undefined;
1868
- let decoded;
1869
- try {
1870
- decoded = decodeURIComponent(path);
1871
- } catch {
1872
- return undefined;
1873
- }
1874
- return decoded.startsWith("/") ? resolve(packageRoot, `.${decoded}`) : resolve(base, decoded);
1875
- }
1876
-
1877
- function mediaVariants(media) {
1878
- const normalized = media.trim().toLowerCase();
1879
- if (!normalized || normalized === "all") return [];
1880
- if (normalized === "print") return ["print"];
1881
- return undefined;
1882
- }
1883
-
1884
- function deduplicateHtmlContexts(contexts) {
1885
- const unique = new Map();
1886
- for (const context of contexts) {
1887
- const key = `${context.cssPath}\0${context.variants.join(":")}\0${context.analyzable}`;
1888
- const existing = unique.get(key);
1889
- if (existing) existing.direct ||= context.direct;
1890
- else unique.set(key, { ...context });
1891
- }
1892
- return [...unique.values()];
1893
- }
1894
-
1895
- function htmlWarning(code, file, start, end, message) {
1896
- return { code, file, start, end, message };
1897
- }
1898
-
1899
- function isProjectInput(workspaceRoot, path) {
1900
- return (
1901
- isWithin(workspaceRoot, path) &&
1902
- !relative(workspaceRoot, path).split(/[\\/]/).includes("node_modules")
1903
- );
1904
- }
1905
-
1906
- async function rejectSymlinkTarget(path, root) {
1907
- for (let current = path; isWithin(root, current); current = dirname(current)) {
1908
- if ((await lstat(current)).isSymbolicLink()) {
1909
- throw new Error(`Refusing to migrate a symbolic-link target: ${path}`);
1910
- }
1911
- if (current === root) break;
1912
- }
1913
- }
1914
-
1915
- async function snapshotFile(snapshots, path) {
1916
- return recordSnapshot(snapshots, path, await readFile(path, "utf8"));
1917
- }
1918
-
1919
- // The compiler just loaded this dependency, so its disappearance is a source
1920
- // change during planning -- a fatal integrity error `--force` must not
1921
- // downgrade to a recoverable package failure.
1922
- async function snapshotLoadedSource(snapshots, path) {
1923
- try {
1924
- return await snapshotFile(snapshots, path);
1925
- } catch (error) {
1926
- if (error.code === "ENOENT") {
1927
- throw new Error(`Source changed during planning: ${path}`);
1928
- }
1929
- throw error;
1930
- }
1931
- }
1932
-
1933
- function recordSnapshot(snapshots, path, source) {
1934
- if (snapshots.has(path) && snapshots.get(path) !== source) {
1935
- throw new Error(`Source changed during planning: ${path}`);
1936
- }
1937
- snapshots.set(path, source);
1938
- return source;
1939
- }
1940
-
1941
- function packageFailure(workspaceRoot, packageRoot, error) {
1942
- const message = error instanceof Error ? error.message : String(error);
1943
- return {
1944
- package: normalizedRelativePath(workspaceRoot, packageRoot) || ".",
1945
- message: message.startsWith(RECOVERABLE_INPUT_ERROR)
1946
- ? message.slice(RECOVERABLE_INPUT_ERROR.length)
1947
- : message,
1948
- };
1949
- }
1950
-
1951
- function isRecoverablePlanningError(error) {
1952
- const message = error instanceof Error ? error.message : String(error);
1953
- return message.startsWith(RECOVERABLE_INPUT_ERROR);
1954
- }
1955
-
1956
- function isMissingStyleCompilerError(error) {
1957
- const message = error instanceof Error ? error.message : String(error);
1958
- return /^(?:Sass|Less) must be installed in the target project\.$/.test(message);
1959
- }
1960
-
1961
- function isIntegrityError(error) {
1962
- const message = error instanceof Error ? error.message : String(error);
1963
- return message.startsWith("Source changed during planning:");
1964
- }
1965
-
1966
- async function readSources(paths, snapshots) {
1967
- return new Map(
1968
- await Promise.all(paths.map(async (path) => [path, await snapshotFile(snapshots, path)])),
1969
- );
1970
- }
1971
-
1972
- async function collectFiles(root, include) {
1973
- const files = [];
1974
- async function visit(directory) {
1975
- const entries = await readdir(directory, { withFileTypes: true });
1976
- entries.sort((left, right) => left.name.localeCompare(right.name));
1977
- for (const entry of entries) {
1978
- const path = join(directory, entry.name);
1979
- if (entry.isDirectory()) {
1980
- if (!IGNORED_DIRECTORIES.has(entry.name)) await visit(path);
1981
- } else if (entry.isFile() && include(path)) {
1982
- files.push(path);
1983
- }
1984
- }
1985
- }
1986
- await visit(root);
1987
- return files;
1988
- }
1989
-
1990
- function extension(path) {
1991
- const match = /\.[^./]+$/.exec(path);
1992
- return match?.[0] ?? "";
1993
- }
1994
-
1995
- function normalizedRelativePath(root, path) {
1996
- return relative(root, path).split(sep).join("/");
1997
- }
1998
-
1999
- function maskCssComments(source) {
2000
- return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\r\n]/g, " "));
2001
- }
2002
-
2003
- function indexStylesheetDependents(styleSources) {
2004
- const dependents = new Map();
2005
- for (const [path, rawSource] of styleSources) {
2006
- const source = maskCssComments(rawSource);
2007
- const references = [
2008
- ...[...source.matchAll(/composes\s*:[^;{}]*?\bfrom\s+["']([^"']+)["']/g)].map(
2009
- (match) => match[1],
2010
- ),
2011
- ...[...source.matchAll(/@(?:use|forward)\s+["']([^"']+)["']/g)].map((match) => match[1]),
2012
- ...[...source.matchAll(/@import\s+(?:\([^)]*\)\s*)?(?:([^;{}]+);|([^;{}\r\n]+))/g)].flatMap(
2013
- (statement) =>
2014
- [...(statement[1] ?? statement[2]).matchAll(/["']([^"']+)["']/g)].map(
2015
- (match) => match[1],
2016
- ),
2017
- ),
2018
- ...[...source.matchAll(/@import\s+url\(\s*([^"'()\s]+)\s*\)/g)].map((match) => match[1]),
2019
- ];
2020
- for (const reference of new Set(references)) {
2021
- for (const target of stylesheetReferenceTargets(path, reference, styleSources)) {
2022
- if (target === path || (!isStylesheetModule(target) && !isPreprocessorPath(target)))
2023
- continue;
2024
- const paths = dependents.get(target) ?? [];
2025
- paths.push(path);
2026
- dependents.set(target, paths);
2027
- }
2028
- }
2029
- }
2030
- for (const [target, paths] of dependents)
2031
- dependents.set(target, [...new Set(paths)].sort(compareStrings));
2032
- return dependents;
2033
- }
2034
-
2035
- function addStyleDependent(styleDependents, target, importer) {
2036
- const paths = styleDependents.get(target) ?? [];
2037
- if (paths.includes(importer)) return;
2038
- paths.push(importer);
2039
- paths.sort(compareStrings);
2040
- styleDependents.set(target, paths);
2041
- }
2042
-
2043
- function stylesheetReferenceTargets(importer, reference, styleSources) {
2044
- const target = resolve(dirname(importer), reference);
2045
- const candidates = STYLESHEET_SYNTAX.has(extension(target))
2046
- ? [target]
2047
- : [...STYLESHEET_SYNTAX.keys()].flatMap((syntax) => [
2048
- `${target}${syntax}`,
2049
- join(dirname(target), `_${basename(target)}${syntax}`),
2050
- join(target, `_index${syntax}`),
2051
- join(target, `index${syntax}`),
2052
- ]);
2053
- return candidates.filter((path) => styleSources.has(path));
2054
- }
2055
-
2056
- function resolveTailwindEntry(stylePaths, styleSources, configuredPath) {
2057
- const entries = stylePaths.filter((path) => {
2058
- if (extension(path) !== ".css") return false;
2059
- const source = maskCssComments(styleSources.get(path));
2060
- return /@import\s+["']tailwindcss(?:\/[^"']*)?["']/.test(source);
2061
- });
2062
- if (configuredPath) return { path: configuredPath, entries };
2063
- if (entries.length === 0)
2064
- throw new Error("No Tailwind v4 CSS entry was found. Pass --tailwind-css.");
2065
- if (entries.length > 1)
2066
- throw new Error("Multiple Tailwind CSS entries were found. Pass --tailwind-css.");
2067
- return { path: entries[0], entries };
2068
- }
2069
-
2070
- async function loadTailwind(packageRoot, tailwindCss, snapshots, workspaceRoot) {
2071
- const projectRequire = createRequire(join(packageRoot, "package.json"));
2072
- let packagePath;
2073
- try {
2074
- packagePath = projectRequire.resolve("tailwindcss/package.json");
2075
- } catch {
2076
- throw new Error("Tailwind v4 must be installed in the target project.");
2077
- }
2078
- const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
2079
- if (!String(packageJson.version).startsWith("4."))
2080
- throw new Error(`Tailwind v4 is required; found ${packageJson.version}.`);
2081
-
2082
- const modulePath = projectRequire.resolve("tailwindcss");
2083
- const tailwindModule = await import(pathToFileURL(modulePath));
2084
- const { __unstable__loadDesignSystem: loadDesignSystem } =
2085
- tailwindModule.default ?? tailwindModule;
2086
- const css = await snapshotFile(snapshots, tailwindCss);
2087
- const base = dirname(tailwindCss);
2088
- const loadModule = createModuleLoader(snapshots, workspaceRoot);
2089
- const loadStylesheet = createStylesheetLoader(
2090
- projectRequire,
2091
- packagePath,
2092
- snapshots,
2093
- workspaceRoot,
2094
- );
2095
- const defaultTheme = await readFile(join(dirname(packagePath), "theme.css"), "utf8");
2096
- const themeTokens = {
2097
- ...extractThemeTokens(defaultTheme),
2098
- ...(await extractThemeTokensFromGraph(css, base, loadStylesheet)),
2099
- };
2100
- const designSystem = await loadDesignSystem(css, { base, loadModule, loadStylesheet });
2101
- return { designSystem, css, path: tailwindCss, themeTokens };
2102
- }
2103
-
2104
- function extractThemeTokens(css) {
2105
- const tokens = {};
2106
- for (const block of css.matchAll(/@theme[^{]*\{([^}]*)\}/gs)) {
2107
- for (const match of block[1].matchAll(/--([\w-]+):\s*([^;{}]+);/g))
2108
- tokens[match[1]] = match[2].trim();
2109
- }
2110
- return tokens;
2111
- }
2112
-
2113
- async function extractThemeTokensFromGraph(css, base, loadStylesheet, seen = new Set()) {
2114
- const tokens = {};
2115
- for (const match of css.matchAll(/@import\s+["']([^"']+)["']/g)) {
2116
- const key = `${base}\0${match[1]}`;
2117
- if (seen.has(key)) continue;
2118
- seen.add(key);
2119
- const loaded = await loadStylesheet(match[1], base);
2120
- Object.assign(
2121
- tokens,
2122
- await extractThemeTokensFromGraph(loaded.content, loaded.base, loadStylesheet, seen),
2123
- );
2124
- }
2125
- return Object.assign(tokens, extractThemeTokens(css));
2126
- }
2127
-
2128
- function invalidCandidates(tailwind, candidates) {
2129
- const generated = tailwind.designSystem.candidatesToCss(candidates);
2130
- return candidates.filter((_, index) => generated[index] === null);
2131
- }
2132
-
2133
- function createModuleLoader(snapshots, workspaceRoot) {
2134
- return async (id, base) => {
2135
- const path = createRequire(join(base, "package.json")).resolve(id);
2136
- if (isProjectInput(workspaceRoot, path)) await snapshotFile(snapshots, path);
2137
- const imported = await import(pathToFileURL(path));
2138
- return { path, base: dirname(path), module: imported.default ?? imported };
2139
- };
2140
- }
2141
-
2142
- function createStylesheetLoader(projectRequire, tailwindPackagePath, snapshots, workspaceRoot) {
2143
- const tailwindRoot = dirname(tailwindPackagePath);
2144
- return async (id, base) => {
2145
- let path;
2146
- if (id === "tailwindcss") path = join(tailwindRoot, "index.css");
2147
- else if (id.startsWith("tailwindcss/")) {
2148
- const subpath = id.slice("tailwindcss/".length);
2149
- path = join(tailwindRoot, subpath.endsWith(".css") ? subpath : `${subpath}.css`);
2150
- } else if (id.startsWith(".") || isAbsolute(id)) path = resolve(base, id);
2151
- else path = projectRequire.resolve(id);
2152
- const content = isProjectInput(workspaceRoot, path)
2153
- ? await snapshotFile(snapshots, path)
2154
- : await readFile(path, "utf8");
2155
- return { content, base: dirname(path) };
2156
- };
2157
- }
2158
-
2159
- function unifiedDiff(path, before, after) {
2160
- const oldLines = before.split("\n");
2161
- const newLines = after.split("\n");
2162
- return [
2163
- `--- a/${path}\n`,
2164
- `+++ b/${path}\n`,
2165
- `@@ -1,${oldLines.length} +1,${newLines.length} @@\n`,
2166
- ...oldLines.map(
2167
- (line, index) => `${index === oldLines.length - 1 && line === "" ? " " : "-"}${line}\n`,
2168
- ),
2169
- ...newLines.map(
2170
- (line, index) => `${index === newLines.length - 1 && line === "" ? " " : "+"}${line}\n`,
2171
- ),
2172
- ].join("");
2173
- }
2174
-
2175
- async function verifySnapshots(snapshots) {
2176
- for (const [path, before] of [...snapshots].sort(([left], [right]) =>
2177
- left.localeCompare(right),
2178
- )) {
2179
- let current;
2180
- try {
2181
- current = await readFile(path, "utf8");
2182
- } catch (error) {
2183
- throw new Error(`Source changed after planning: ${path} (${error.code ?? error.message})`);
2184
- }
2185
- if (current !== before) throw new Error(`Source changed after planning: ${path}`);
2186
- }
2187
- }
2188
-
2189
- async function writeChanges(changes, deletions) {
2190
- const token = `${process.pid}-${Date.now()}`;
2191
- const staged = changes.map((change, index) => [
2192
- join(dirname(change.path), `.${basename(change.path)}.tw-migrate-${token}-${index}`),
2193
- change,
2194
- ]);
2195
- const backups = [...changes, ...deletions].map((change, index) => [
2196
- join(dirname(change.path), `.${basename(change.path)}.tw-migrate-backup-${token}-${index}`),
2197
- change.path,
2198
- ]);
2199
- const backedUp = [];
2200
- let succeeded = false;
2201
- try {
2202
- for (const [temporaryPath, change] of staged) {
2203
- const { mode } = await stat(change.path);
2204
- await writeFile(temporaryPath, change.source);
2205
- await chmod(temporaryPath, mode & 0o777);
2206
- }
2207
- for (const [backupPath, originalPath] of backups) {
2208
- await rename(originalPath, backupPath);
2209
- backedUp.push([backupPath, originalPath]);
2210
- }
2211
- for (const [temporaryPath, change] of staged) await rename(temporaryPath, change.path);
2212
- succeeded = true;
2213
- } finally {
2214
- if (succeeded) {
2215
- await Promise.all(backups.map(([backupPath]) => rm(backupPath, { force: true })));
2216
- } else {
2217
- for (const [backupPath, originalPath] of backedUp.reverse()) {
2218
- try {
2219
- await rename(backupPath, originalPath);
2220
- } catch {}
2221
- }
2222
- }
2223
- await Promise.all(staged.map(([temporaryPath]) => rm(temporaryPath, { force: true })));
2224
- }
2225
- }