tw-migrate 0.1.0 → 0.2.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 CHANGED
@@ -1,12 +1,23 @@
1
- import { execFile } from 'node:child_process';
2
- import { createRequire } from 'node:module';
3
- import { chmod, lstat, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
4
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
- import { pathToFileURL } from 'node:url';
6
- import { promisify } from 'node:util';
7
-
8
- import { parseHtmlSource } from './html.js';
9
- import { planBatchMigration, validateCss } from './native.js';
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";
10
21
  import {
11
22
  compileLessEntry,
12
23
  compileSassEntry,
@@ -14,28 +25,40 @@ import {
14
25
  isSassPath,
15
26
  loadProjectLess,
16
27
  loadProjectSass,
17
- } from './style-compiler.js';
28
+ } from "./style-compiler.js";
18
29
 
19
30
  const run = promisify(execFile);
20
- const SOURCE_EXTENSIONS = new Set(['.html', '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts']);
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
+ ]);
21
43
  const STYLESHEET_SYNTAX = new Map([
22
- ['.css', 'css'],
23
- ['.scss', 'scss'],
24
- ['.sass', 'sass'],
25
- ['.less', 'less'],
44
+ [".css", "css"],
45
+ [".scss", "scss"],
46
+ [".sass", "sass"],
47
+ [".less", "less"],
26
48
  ]);
27
- const IGNORED_DIRECTORIES = new Set(['.git', '.next', 'build', 'dist', 'node_modules']);
28
- const RECOVERABLE_INPUT_ERROR = 'TW_MIGRATE_RECOVERABLE_INPUT:';
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);
29
52
 
30
53
  export async function migrate(options = {}) {
31
- if ('cssFile' in options) {
32
- throw new TypeError('cssFile has been replaced by styleFile');
54
+ if ("cssFile" in options) {
55
+ throw new TypeError("cssFile has been replaced by styleFile");
33
56
  }
34
57
  if (options.styleFile && options.workspaces) {
35
- throw new TypeError('styleFile cannot be combined with workspaces');
58
+ throw new TypeError("styleFile cannot be combined with workspaces");
36
59
  }
37
60
  if (options.tailwindCss && options.workspaces) {
38
- throw new TypeError('tailwindCss cannot be combined with workspaces');
61
+ throw new TypeError("tailwindCss cannot be combined with workspaces");
39
62
  }
40
63
 
41
64
  const scope = await resolveScope(options);
@@ -43,9 +66,14 @@ export async function migrate(options = {}) {
43
66
 
44
67
  // A prior interrupted run's scope is unknowable from the current flags
45
68
  // (it may have covered other packages), so scan the whole workspace root.
46
- const leftovers = await collectFiles(workspaceRoot, (path) => basename(path).includes('.tw-migrate-'));
69
+ const leftovers = await collectFiles(workspaceRoot, (path) =>
70
+ basename(path).includes(".tw-migrate-"),
71
+ );
47
72
  if (leftovers.length > 0) {
48
- const listed = leftovers.sort().map((path) => ` ${normalizedRelativePath(cwd, path)}`).join('\n');
73
+ const listed = leftovers
74
+ .sort(compareStrings)
75
+ .map((path) => ` ${normalizedRelativePath(cwd, path)}`)
76
+ .join("\n");
49
77
  throw new Error(
50
78
  `Found leftover tw-migrate files from an interrupted run:\n${listed}\n` +
51
79
  'Restore each ".<name>.tw-migrate-backup-*" file by renaming it back to "<name>", ' +
@@ -58,22 +86,28 @@ export async function migrate(options = {}) {
58
86
  const sourcePaths = scope.scannedPaths.filter((path) => SOURCE_EXTENSIONS.has(extension(path)));
59
87
  const [styleSources, sourceCandidates] = await Promise.all([
60
88
  readSources(stylePaths, snapshots),
61
- Promise.all(sourcePaths.map(async (path) => {
62
- const source = await readFile(path, 'utf8');
63
- // Scan-only scripts are always retained as reference-only inputs: even
64
- // without a ".module." mention they can render components whose trees
65
- // the closed-world relationship proofs must see. Scan-only HTML matters
66
- // solely as a potential stylesheet consumer, and HTML entities can
67
- // encode any part of a linked filename, so retain ignored HTML
68
- // containing a link for parse5 to classify safely.
69
- const mayReferenceModule = extension(path) !== '.html' || /<link\b/i.test(source);
70
- if (!scope.targetable.has(path) && !mayReferenceModule) return undefined;
71
- return { path, source: recordSnapshot(snapshots, path, source) };
72
- })),
73
- ...selectedPackages.map((packageRoot) => snapshotFile(snapshots, join(packageRoot, 'package.json'))),
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
+ ),
74
106
  ]);
75
107
  const sourceFiles = sourceCandidates.filter(Boolean);
76
- if (explicitStyle && !styleSources.has(explicitStyle)) {
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)) {
77
111
  styleSources.set(explicitStyle, await snapshotFile(snapshots, explicitStyle));
78
112
  }
79
113
  if (configuredEntry && !styleSources.has(configuredEntry)) {
@@ -110,28 +144,48 @@ export async function migrate(options = {}) {
110
144
  const deleted = [...deletedPaths]
111
145
  .map((path) => ({ path, before: originals.get(path) }))
112
146
  .sort((left, right) => left.path.localeCompare(right.path));
113
- const operations = [...changed, ...deleted].sort((left, right) => left.path.localeCompare(right.path));
147
+ const operations = [...changed, ...deleted].sort((left, right) =>
148
+ left.path.localeCompare(right.path),
149
+ );
114
150
  const changedFiles = operations.map((file) => normalizedRelativePath(cwd, file.path));
115
151
  const diff = operations
116
- .map((file) => unifiedDiff(normalizedRelativePath(cwd, file.path), file.before, 'source' in file ? file.source : ''))
117
- .join('');
152
+ .map((file) =>
153
+ unifiedDiff(
154
+ normalizedRelativePath(cwd, file.path),
155
+ file.before,
156
+ "source" in file ? file.source : "",
157
+ ),
158
+ )
159
+ .join("");
118
160
 
119
161
  if (options.write && operations.length > 0) {
120
162
  await verifySnapshots(snapshots);
121
163
  await writeChanges(changed, deleted);
122
164
  }
123
165
 
124
- warnings.sort((left, right) =>
125
- left.file.localeCompare(right.file) || left.start - right.start || left.end - right.end || left.code.localeCompare(right.code));
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
+ );
126
173
  failures.sort((left, right) => left.package.localeCompare(right.package));
127
174
  return {
128
175
  changedFiles,
129
176
  diff,
130
177
  convertedRules,
131
178
  retainedRules,
132
- rules: rules.map((rule) => ({ ...rule, file: normalizedRelativePath(cwd, rule.file) })),
133
- candidates: [...candidates].sort(),
134
- warnings: warnings.map((warning) => ({ ...warning, file: normalizedRelativePath(cwd, warning.file) })),
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
+ })),
135
189
  failures,
136
190
  };
137
191
  }
@@ -140,38 +194,43 @@ async function resolveScope(options) {
140
194
  const cwd = await realpath(resolve(options.cwd ?? process.cwd()));
141
195
  const currentPackage = await findPackageRoot(cwd);
142
196
  const gitRoot = await findGitRoot(currentPackage);
143
- const workspaceRoot = gitRoot && !(await isIgnoredByGit(gitRoot, currentPackage))
144
- ? gitRoot
145
- : currentPackage;
197
+ const workspaceRoot =
198
+ gitRoot && !(await isIgnoredByGit(gitRoot, currentPackage)) ? gitRoot : currentPackage;
146
199
  const allPaths = await discoverFiles(workspaceRoot, workspaceRoot === gitRoot);
147
200
  // Ignore filtering scopes what gets migrated, never what gets scanned:
148
201
  // gitignored consumers and stylesheets must still block unsafe deletion.
149
- const scannedPaths = workspaceRoot === gitRoot
150
- ? [...new Set([...allPaths, ...(await collectFiles(workspaceRoot, isRelevantDiscoveredFile))])]
151
- : [...allPaths];
202
+ const scannedPaths =
203
+ workspaceRoot === gitRoot
204
+ ? [
205
+ ...new Set([
206
+ ...allPaths,
207
+ ...(await collectFiles(workspaceRoot, isRelevantDiscoveredFile)),
208
+ ]),
209
+ ]
210
+ : [...allPaths];
152
211
  const explicitStyle = options.styleFile ? resolve(cwd, options.styleFile) : undefined;
153
212
  const configuredEntry = options.tailwindCss ? resolve(cwd, options.tailwindCss) : undefined;
154
- if (explicitStyle && !isStylesheetPath(explicitStyle)) {
155
- throw new TypeError('Only .css, .scss, .sass, and .less files can be migrated');
213
+ if (explicitStyle && !isStylesheetPath(explicitStyle) && extension(explicitStyle) !== ".vue") {
214
+ throw new TypeError("Only .css, .scss, .sass, .less, and .vue files can be migrated");
156
215
  }
157
- if (configuredEntry && extension(configuredEntry) !== '.css') {
158
- throw new TypeError('The Tailwind CSS entry must be a .css file');
216
+ if (configuredEntry && extension(configuredEntry) !== ".css") {
217
+ throw new TypeError("The Tailwind CSS entry must be a .css file");
159
218
  }
160
219
  if (configuredEntry && !(await stat(configuredEntry)).isFile()) {
161
- throw new TypeError('The Tailwind CSS entry must be a file');
220
+ throw new TypeError("The Tailwind CSS entry must be a file");
162
221
  }
163
222
  for (const path of [explicitStyle, configuredEntry]) {
164
223
  if (path) await rejectSymlinkTarget(path, currentPackage);
165
224
  }
166
225
  if (explicitStyle && !isWithin(currentPackage, explicitStyle)) {
167
- throw new TypeError('The selected stylesheet must belong to the current package');
226
+ throw new TypeError("The selected stylesheet must belong to the current package");
168
227
  }
169
228
  for (const path of [explicitStyle, configuredEntry]) {
170
229
  if (path && !allPaths.includes(path)) allPaths.push(path);
171
230
  if (path && !scannedPaths.includes(path)) scannedPaths.push(path);
172
231
  }
173
- allPaths.sort();
174
- scannedPaths.sort();
232
+ allPaths.sort(compareStrings);
233
+ scannedPaths.sort(compareStrings);
175
234
  const targetable = new Set(allPaths);
176
235
 
177
236
  const allPackageRoots = await discoverPackageRoots(workspaceRoot, allPaths);
@@ -181,13 +240,15 @@ async function resolveScope(options) {
181
240
  const owner = await findPackageRoot(dirname(path));
182
241
  if (!allPackageRoots.includes(owner)) allPackageRoots.push(owner);
183
242
  }
184
- allPackageRoots.sort();
185
- const pathOwners = new Map(scannedPaths.map((path) => [path, owningPackage(path, allPackageRoots)]));
243
+ allPackageRoots.sort(compareStrings);
244
+ const pathOwners = new Map(
245
+ scannedPaths.map((path) => [path, owningPackage(path, allPackageRoots)]),
246
+ );
186
247
  if (explicitStyle && pathOwners.get(explicitStyle) !== currentPackage) {
187
- throw new TypeError('The selected stylesheet must belong to the current package');
248
+ throw new TypeError("The selected stylesheet must belong to the current package");
188
249
  }
189
250
  if (configuredEntry && pathOwners.get(configuredEntry) !== currentPackage) {
190
- throw new TypeError('The Tailwind CSS entry must belong to the current package');
251
+ throw new TypeError("The Tailwind CSS entry must belong to the current package");
191
252
  }
192
253
  const selectedPackages = options.workspaces ? allPackageRoots : [currentPackage];
193
254
  return {
@@ -216,6 +277,7 @@ async function planPackage(context, packageRoot) {
216
277
  pathOwners,
217
278
  targetable,
218
279
  writablePackages,
280
+ scannedPaths,
219
281
  } = context;
220
282
  let preparedHtml;
221
283
  try {
@@ -231,15 +293,50 @@ async function planPackage(context, packageRoot) {
231
293
  if (!options.force || isIntegrityError(error)) throw error;
232
294
  return { failure: packageFailure(workspaceRoot, packageRoot, error) };
233
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
+ }
234
324
  const packageSources = [
235
- ...sourceFiles.filter((file) => extension(file.path) !== '.html'),
325
+ ...sourceFiles
326
+ .filter((file) => extension(file.path) !== ".html")
327
+ .map((file) => preparedVue.files.get(file.path) ?? file),
236
328
  ...preparedHtml.files,
237
329
  ];
238
330
  const ownedStyles = [...styleSources.keys()].filter(
239
- (path) => pathOwners.get(path) === packageRoot
240
- && (targetable.has(path) || preparedHtml.stylePaths.has(path)),
331
+ (path) =>
332
+ pathOwners.get(path) === packageRoot &&
333
+ (targetable.has(path) ||
334
+ preparedHtml.stylePaths.has(path) ||
335
+ preparedVue.stylePaths.has(path)),
241
336
  );
242
- if (ownedStyles.length === 0) return {};
337
+ if (ownedStyles.length === 0 && preparedVue.stylesheets.length === 0) {
338
+ return vueWarningsOnlyResult(preparedVue);
339
+ }
243
340
 
244
341
  let tailwindPath;
245
342
  let tailwindEntries;
@@ -255,18 +352,27 @@ async function planPackage(context, packageRoot) {
255
352
  }
256
353
 
257
354
  const excludedEntries = new Set([...tailwindEntries, tailwindPath]);
258
- const targets = explicitStyle
259
- ? [explicitStyle]
260
- : ownedStyles.filter((path) =>
261
- !excludedEntries.has(path)
262
- && !preparedHtml.generatedPaths.has(path)
263
- && (!isPreprocessorPath(path)
264
- || preparedHtml.stylePaths.has(path)
265
- || packageSources.some((file) => sourceReferencesStyle(file, path))),
266
- );
267
- if (targets.length === 0) return {};
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
+ }
268
374
  if (targets.some((path) => excludedEntries.has(path))) {
269
- throw new Error('The Tailwind CSS entry cannot be migrated.');
375
+ throw new Error("The Tailwind CSS entry cannot be migrated.");
270
376
  }
271
377
 
272
378
  let tailwind;
@@ -281,8 +387,9 @@ async function planPackage(context, packageRoot) {
281
387
  const owner = pathOwners.get(file.path);
282
388
  return {
283
389
  ...file,
284
- writable: targetable.has(file.path)
285
- && (options.workspaces ? writablePackages.has(owner) : owner === packageRoot),
390
+ writable:
391
+ targetable.has(file.path) &&
392
+ (options.workspaces ? writablePackages.has(owner) : owner === packageRoot),
286
393
  };
287
394
  });
288
395
  let stylesheets;
@@ -291,9 +398,9 @@ async function planPackage(context, packageRoot) {
291
398
  try {
292
399
  stylesheets = [];
293
400
  const compilerDependents = new Map();
294
- for (const stylePath of targets.sort()) {
401
+ for (const stylePath of targets.sort(compareStrings)) {
295
402
  await rejectSymlinkTarget(stylePath, packageRoot);
296
- const isPartial = isSassPath(stylePath) && basename(stylePath).startsWith('_');
403
+ const isPartial = isSassPath(stylePath) && basename(stylePath).startsWith("_");
297
404
  const stylesheet = {
298
405
  cssPath: stylePath,
299
406
  cssSource: styleSources.get(stylePath),
@@ -309,7 +416,7 @@ async function planPackage(context, packageRoot) {
309
416
  // Compile the snapshotted source, not the on-disk file: code loaded
310
417
  // during planning (e.g. Tailwind plugins) may have rewritten it since.
311
418
  compiled = await compileSassEntry(sass, stylePath, stylesheet.cssSource);
312
- } else if (extension(stylePath) === '.less') {
419
+ } else if (extension(stylePath) === ".less") {
313
420
  less ??= await loadProjectLess(packageRoot);
314
421
  compiled = await compileLessEntry(less, stylePath, stylesheet.cssSource);
315
422
  }
@@ -317,7 +424,7 @@ async function planPackage(context, packageRoot) {
317
424
  validateCss(compiled.css);
318
425
  for (const loadedPath of compiled.loadedPaths) {
319
426
  if (!isProjectInput(workspaceRoot, loadedPath)) continue;
320
- const source = await snapshotFile(snapshots, loadedPath);
427
+ const source = await snapshotLoadedSource(snapshots, loadedPath);
321
428
  if (!styleSources.has(loadedPath)) styleSources.set(loadedPath, source);
322
429
  if (loadedPath !== stylePath) {
323
430
  const dependents = compilerDependents.get(loadedPath) ?? [];
@@ -334,13 +441,16 @@ async function planPackage(context, packageRoot) {
334
441
  const dependents = compilerDependents.get(stylesheet.cssPath) ?? [];
335
442
  if (dependents.length === 0) continue;
336
443
  stylesheet.isPartial = true;
337
- stylesheet.cssDependents = [...new Set([...stylesheet.cssDependents, ...dependents])].sort();
444
+ stylesheet.cssDependents = [...new Set([...stylesheet.cssDependents, ...dependents])].sort(
445
+ compareStrings,
446
+ );
338
447
  }
339
448
  } catch (error) {
340
449
  if (!options.force || isIntegrityError(error)) throw error;
341
450
  return { failure: packageFailure(workspaceRoot, packageRoot, error) };
342
451
  }
343
452
 
453
+ stylesheets.push(...preparedVue.stylesheets);
344
454
  const request = {
345
455
  stylesheets,
346
456
  tailwindPath: tailwind.path,
@@ -366,18 +476,75 @@ async function planPackage(context, packageRoot) {
366
476
 
367
477
  removeMigratedHtmlLinks(plan, preparedHtml);
368
478
  plan.warnings.push(...preparedHtml.warnings);
369
- for (const file of plan.files.filter((file) => extension(file.path) === '.html')) {
370
- parseHtmlSource(file.path, file.source);
371
- }
372
- for (const stylesheet of stylesheets.filter((stylesheet) => isPreprocessorPath(stylesheet.cssPath))) {
373
- const changed = plan.files.find((file) => file.path === stylesheet.cssPath);
374
- if (!changed && !plan.deletedFiles.includes(stylesheet.cssPath)) continue;
375
- const source = changed?.source ?? '';
376
- if (isSassPath(stylesheet.cssPath)) {
377
- validateCss((await compileSassEntry(sass, stylesheet.cssPath, source)).css);
378
- } else {
379
- validateCss((await compileLessEntry(less, stylesheet.cssPath, source)).css);
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);
380
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) };
381
548
  }
382
549
  return { plan };
383
550
  }
@@ -398,12 +565,18 @@ function replanCompileFailures(tailwind, request, initialPlan) {
398
565
  for (const rule of plan.rules) {
399
566
  const failed = rule.candidates.filter((candidate) => failing.includes(candidate));
400
567
  if (failed.length === 0) continue;
401
- let blocked = blockedByStylesheet.get(rule.file);
402
- if (!blocked) blockedByStylesheet.set(rule.file, blocked = new Map());
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()));
403
573
  const key = `${rule.ruleId.start}-${rule.ruleId.end}`;
404
574
  let entry = blocked.get(key);
405
575
  if (!entry) {
406
- blocked.set(key, entry = { ruleId: rule.ruleId, authoredSpan: rule.authoredSpan, candidates: new Set() });
576
+ blocked.set(
577
+ key,
578
+ (entry = { ruleId: rule.ruleId, authoredSpan: rule.authoredSpan, candidates: new Set() }),
579
+ );
407
580
  progressed = true;
408
581
  }
409
582
  for (const candidate of failed) entry.candidates.add(candidate);
@@ -411,20 +584,29 @@ function replanCompileFailures(tailwind, request, initialPlan) {
411
584
  if (!progressed || iteration >= maxIterations) {
412
585
  throw new Error(`Tailwind did not generate CSS for candidate: ${failing[0]}`);
413
586
  }
414
- plan = JSON.parse(planBatchMigration(JSON.stringify({
415
- ...request,
416
- stylesheets: request.stylesheets.map((stylesheet) => ({
417
- ...stylesheet,
418
- blockedRules: [...(blockedByStylesheet.get(stylesheet.cssPath)?.values() ?? [])]
419
- .map((entry) => entry.ruleId),
420
- })),
421
- })));
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
+ );
422
600
  }
423
- for (const [cssPath, blocked] of blockedByStylesheet) {
601
+ for (const [index, blocked] of blockedByStylesheet) {
602
+ const cssPath = request.stylesheets[index].cssPath;
424
603
  for (const { authoredSpan, candidates } of blocked.values()) {
425
- const failed = [...candidates].sort().map((candidate) => `\`${candidate}\``).join(', ');
604
+ const failed = [...candidates]
605
+ .sort(compareStrings)
606
+ .map((candidate) => `\`${candidate}\``)
607
+ .join(", ");
426
608
  plan.warnings.push({
427
- code: 'candidate-compilation-failure',
609
+ code: "candidate-compilation-failure",
428
610
  file: cssPath,
429
611
  // ruleId is a compiled-domain span; warnings anchor to the authored file.
430
612
  start: authoredSpan.start,
@@ -451,8 +633,8 @@ function removeMigratedHtmlLinks(plan, preparedHtml) {
451
633
  const original = preparedHtml.files.find((file) => file.path === filePath);
452
634
  if (!original) continue;
453
635
  const source = planned?.source ?? original.source;
454
- const links = parseHtmlSource(filePath, source).links
455
- .filter((link) => removable.has(`${link.href}\0${link.media}`))
636
+ const links = parseHtmlSource(filePath, source)
637
+ .links.filter((link) => removable.has(`${link.href}\0${link.media}`))
456
638
  .sort((left, right) => right.tagStart - left.tagStart);
457
639
  let bytes = Buffer.from(source);
458
640
  for (const link of links) {
@@ -477,14 +659,16 @@ function mergePlans(plans, originals) {
477
659
 
478
660
  for (const plan of plans) {
479
661
  for (const file of plan.files) {
480
- if (!originals.has(file.path)) throw new Error(`Planned file is outside the source snapshot: ${file.path}`);
662
+ if (!originals.has(file.path))
663
+ throw new Error(`Planned file is outside the source snapshot: ${file.path}`);
481
664
  if (filesByPath.has(file.path) || deletedPaths.has(file.path)) {
482
665
  throw new Error(`Multiple package groups planned changes for ${file.path}`);
483
666
  }
484
667
  filesByPath.set(file.path, file);
485
668
  }
486
669
  for (const path of plan.deletedFiles) {
487
- if (!originals.has(path)) throw new Error(`Planned deletion is outside the source snapshot: ${path}`);
670
+ if (!originals.has(path))
671
+ throw new Error(`Planned deletion is outside the source snapshot: ${path}`);
488
672
  if (filesByPath.has(path) || deletedPaths.has(path)) {
489
673
  throw new Error(`Multiple package groups planned changes for ${path}`);
490
674
  }
@@ -510,10 +694,10 @@ async function findPackageRoot(start) {
510
694
  let directory = start;
511
695
  while (true) {
512
696
  try {
513
- await readFile(join(directory, 'package.json'), 'utf8');
697
+ await readFile(join(directory, "package.json"), "utf8");
514
698
  return directory;
515
699
  } catch (error) {
516
- if (error.code !== 'ENOENT') throw error;
700
+ if (error.code !== "ENOENT") throw error;
517
701
  }
518
702
  const parent = dirname(directory);
519
703
  if (parent === directory) throw new Error(`No package.json was found from ${start}`);
@@ -523,7 +707,7 @@ async function findPackageRoot(start) {
523
707
 
524
708
  async function findGitRoot(cwd) {
525
709
  try {
526
- const { stdout } = await run('git', ['rev-parse', '--show-toplevel'], { cwd });
710
+ const { stdout } = await run("git", ["rev-parse", "--show-toplevel"], { cwd });
527
711
  return resolve(stdout.trim());
528
712
  } catch {
529
713
  return undefined;
@@ -533,7 +717,7 @@ async function findGitRoot(cwd) {
533
717
  async function isIgnoredByGit(gitRoot, path) {
534
718
  if (gitRoot === path) return false;
535
719
  try {
536
- await run('git', ['check-ignore', '-q', '--', relative(gitRoot, path)], { cwd: gitRoot });
720
+ await run("git", ["check-ignore", "-q", "--", relative(gitRoot, path)], { cwd: gitRoot });
537
721
  return true;
538
722
  } catch {
539
723
  return false;
@@ -543,37 +727,36 @@ async function isIgnoredByGit(gitRoot, path) {
543
727
  async function discoverFiles(root, useGit) {
544
728
  if (!useGit) return collectFiles(root, isRelevantDiscoveredFile);
545
729
 
546
- const { stdout } = await run(
547
- 'git',
548
- ['ls-files', '-co', '--exclude-standard', '-z', '--', '.'],
549
- { cwd: root, maxBuffer: 64 * 1024 * 1024 },
550
- );
730
+ const { stdout } = await run("git", ["ls-files", "-co", "--exclude-standard", "-z", "--", "."], {
731
+ cwd: root,
732
+ maxBuffer: 64 * 1024 * 1024,
733
+ });
551
734
  const paths = stdout
552
- .split('\0')
735
+ .split("\0")
553
736
  .filter(Boolean)
554
737
  .map((path) => resolve(root, path))
555
738
  .filter((path) => !hasIgnoredDirectory(root, path) && isRelevantDiscoveredFile(path));
556
- const existing = await Promise.all(paths.map(async (path) => {
557
- try {
558
- return (await stat(path)).isFile() ? path : undefined;
559
- } catch {
560
- return undefined;
561
- }
562
- }));
563
- return existing.filter(Boolean).sort();
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);
564
749
  }
565
750
 
566
751
  async function discoverPackageRoots(workspaceRoot, paths) {
567
- const roots = paths
568
- .filter((path) => basename(path) === 'package.json')
569
- .map(dirname);
752
+ const roots = paths.filter((path) => basename(path) === "package.json").map(dirname);
570
753
  try {
571
- await readFile(join(workspaceRoot, 'package.json'), 'utf8');
754
+ await readFile(join(workspaceRoot, "package.json"), "utf8");
572
755
  roots.push(workspaceRoot);
573
756
  } catch (error) {
574
- if (error.code !== 'ENOENT') throw error;
757
+ if (error.code !== "ENOENT") throw error;
575
758
  }
576
- return [...new Set(roots)].sort();
759
+ return [...new Set(roots)].sort(compareStrings);
577
760
  }
578
761
 
579
762
  function owningPackage(path, packageRoots) {
@@ -587,13 +770,17 @@ function isWithin(root, path) {
587
770
  }
588
771
 
589
772
  function hasIgnoredDirectory(root, path) {
590
- return relative(root, path).split(/[\\/]/).some((part) => IGNORED_DIRECTORIES.has(part));
773
+ return relative(root, path)
774
+ .split(/[\\/]/)
775
+ .some((part) => IGNORED_DIRECTORIES.has(part));
591
776
  }
592
777
 
593
778
  function isRelevantDiscoveredFile(path) {
594
- return basename(path) === 'package.json'
595
- || isStylesheetPath(path)
596
- || SOURCE_EXTENSIONS.has(extension(path));
779
+ return (
780
+ basename(path) === "package.json" ||
781
+ isStylesheetPath(path) ||
782
+ SOURCE_EXTENSIONS.has(extension(path))
783
+ );
597
784
  }
598
785
 
599
786
  function isStylesheetPath(path) {
@@ -611,9 +798,707 @@ function isStylesheetModule(path) {
611
798
 
612
799
  function sourceReferencesStyle(file, stylePath) {
613
800
  let importPath = normalizedRelativePath(dirname(file.path), stylePath);
614
- if (!importPath.startsWith('.')) importPath = `./${importPath}`;
615
- return [`'${importPath}'`, `"${importPath}"`, `\`${importPath}\``]
616
- .some((literal) => file.source.includes(literal));
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
+ };
617
1502
  }
618
1503
 
619
1504
  async function preparePackageHtml({
@@ -630,7 +1515,7 @@ async function preparePackageHtml({
630
1515
  const removableLinks = [];
631
1516
  const warnings = [];
632
1517
 
633
- const htmlFiles = sourceFiles.filter((file) => extension(file.path) === '.html');
1518
+ const htmlFiles = sourceFiles.filter((file) => extension(file.path) === ".html");
634
1519
  // Package-owned HTML goes first so stylesheets it discovers are claimed for
635
1520
  // this package before foreign consumers are matched against that ownership.
636
1521
  const orderedFiles = [
@@ -651,57 +1536,71 @@ async function preparePackageHtml({
651
1536
  const base = parsed.bases[0];
652
1537
  if (base) {
653
1538
  const baseReference = base.href.split(/[?#]/, 1)[0];
654
- const basePath = base.writable && (baseReference === ''
655
- ? file.path
656
- : localHtmlReference(referenceRoot, dirname(file.path), base.href));
1539
+ const basePath =
1540
+ base.writable &&
1541
+ (baseReference === ""
1542
+ ? file.path
1543
+ : localHtmlReference(referenceRoot, dirname(file.path), base.href));
657
1544
  if (!basePath || !isWithin(referenceRoot, basePath)) {
658
1545
  if (!foreign) {
659
- warnings.push(htmlWarning(
660
- 'unsupported-html-base',
661
- file.path,
662
- base.start,
663
- base.end,
664
- 'A remote or unrepresentable base URL prevents safe stylesheet link resolution.',
665
- ));
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
+ );
666
1555
  }
667
1556
  linkBase = undefined;
668
1557
  } else {
669
- linkBase = base.href.split(/[?#]/, 1)[0].endsWith('/') ? basePath : dirname(basePath);
1558
+ linkBase = base.href.split(/[?#]/, 1)[0].endsWith("/") ? basePath : dirname(basePath);
670
1559
  }
671
1560
  }
672
1561
  for (const link of parsed.links) {
673
1562
  const linkedPath = linkBase && localHtmlReference(referenceRoot, linkBase, link.href);
674
- if (!linkedPath || !isWithin(packageRoot, linkedPath)
675
- || (foreign && pathOwners.get(linkedPath) !== packageRoot)) {
1563
+ if (
1564
+ !linkedPath ||
1565
+ !isWithin(packageRoot, linkedPath) ||
1566
+ (foreign && pathOwners.get(linkedPath) !== packageRoot)
1567
+ ) {
676
1568
  if (!foreign) {
677
- warnings.push(htmlWarning(
678
- 'unsupported-html-stylesheet-link',
679
- file.path,
680
- link.start,
681
- link.end,
682
- 'Only local package stylesheet links are analyzed.',
683
- ));
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
+ );
684
1578
  }
685
1579
  continue;
686
1580
  }
687
1581
  const variants = mediaVariants(link.media);
688
1582
  if (variants === undefined) {
689
- const cssPath = (foreign ? undefined : inferredPreprocessorPath({
690
- path: linkedPath,
691
- packageRoot,
692
- styleSources,
693
- pathOwners,
694
- styleDependents,
695
- })) ?? linkedPath;
1583
+ const cssPath =
1584
+ (foreign
1585
+ ? undefined
1586
+ : inferredPreprocessorPath({
1587
+ path: linkedPath,
1588
+ packageRoot,
1589
+ styleSources,
1590
+ pathOwners,
1591
+ styleDependents,
1592
+ })) ?? linkedPath;
696
1593
  contexts.push({ cssPath, variants: [], direct: true, analyzable: false });
697
1594
  if (!foreign) {
698
- warnings.push(htmlWarning(
699
- 'unsupported-link-media',
700
- file.path,
701
- link.start,
702
- link.end,
703
- `The stylesheet link media condition ${JSON.stringify(link.media)} cannot be represented safely.`,
704
- ));
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
+ );
705
1604
  }
706
1605
  continue;
707
1606
  }
@@ -736,13 +1635,15 @@ async function preparePackageHtml({
736
1635
  if (foreign && contexts.length === 0) continue;
737
1636
  if (!foreign && contexts.length > 0) {
738
1637
  for (const attribute of parsed.dynamicAttributes) {
739
- warnings.push(htmlWarning(
740
- 'dynamic-html-attribute',
741
- file.path,
742
- attribute.start,
743
- attribute.end,
744
- 'This HTML attribute is not a safely writable quoted literal.',
745
- ));
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
+ );
746
1647
  }
747
1648
  }
748
1649
  files.push({
@@ -758,29 +1659,31 @@ async function preparePackageHtml({
758
1659
  }
759
1660
 
760
1661
  async function collectHtmlStyleContexts(state) {
761
- const key = `${state.path}\0${state.variants.join(':')}`;
1662
+ const key = `${state.path}\0${state.variants.join(":")}`;
762
1663
  if (state.visited.has(key)) return;
763
1664
  state.visited.add(key);
764
1665
  if (!isStylesheetPath(state.path)) return;
765
1666
  const owner = state.pathOwners.get(state.path);
766
1667
  if (owner && owner !== state.packageRoot) {
767
- state.warnings.push(htmlWarning(
768
- 'cross-package-stylesheet-link',
769
- state.path,
770
- 0,
771
- 0,
772
- 'A stylesheet owned by another package is not analyzed outside workspace mode.',
773
- ));
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
+ );
774
1677
  return;
775
1678
  }
776
1679
 
777
1680
  let source;
778
1681
  try {
779
- source = state.styleSources.get(state.path)
780
- ?? await snapshotFile(state.snapshots, state.path);
1682
+ source =
1683
+ state.styleSources.get(state.path) ?? (await snapshotFile(state.snapshots, state.path));
781
1684
  } catch (error) {
782
- if (error.code === 'ENOENT') {
783
- if (extension(state.path) === '.css') addInferredPreprocessorContext(state);
1685
+ if (error.code === "ENOENT") {
1686
+ if (extension(state.path) === ".css") addInferredPreprocessorContext(state);
784
1687
  return;
785
1688
  }
786
1689
  throw error;
@@ -788,7 +1691,7 @@ async function collectHtmlStyleContexts(state) {
788
1691
  if (!state.styleSources.has(state.path)) state.styleSources.set(state.path, source);
789
1692
  if (!owner) state.pathOwners.set(state.path, state.packageRoot);
790
1693
 
791
- if (extension(state.path) === '.css' && addInferredPreprocessorContext(state)) return;
1694
+ if (extension(state.path) === ".css" && addInferredPreprocessorContext(state)) return;
792
1695
 
793
1696
  state.stylePaths.add(state.path);
794
1697
  state.contexts.push({
@@ -797,18 +1700,20 @@ async function collectHtmlStyleContexts(state) {
797
1700
  direct: state.direct,
798
1701
  analyzable: true,
799
1702
  });
800
- if (extension(state.path) !== '.css') return;
1703
+ if (extension(state.path) !== ".css") return;
801
1704
 
802
1705
  for (const imported of cssImports(source)) {
803
1706
  const variants = mediaVariants(imported.media);
804
1707
  if (variants === undefined) {
805
- state.warnings.push(htmlWarning(
806
- 'unsupported-link-media',
807
- state.path,
808
- imported.start,
809
- imported.end,
810
- `The stylesheet import media condition ${JSON.stringify(imported.media)} cannot be represented safely.`,
811
- ));
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
+ );
812
1717
  continue;
813
1718
  }
814
1719
  const importedPath = localHtmlReference(state.packageRoot, dirname(state.path), imported.href);
@@ -816,8 +1721,10 @@ async function collectHtmlStyleContexts(state) {
816
1721
  // Link-discovered stylesheets never went through indexStylesheetDependents,
817
1722
  // so record their import edges here or deletion could leave this importer
818
1723
  // pointing at a removed module.
819
- if (importedPath !== state.path
820
- && (isStylesheetModule(importedPath) || isPreprocessorPath(importedPath))) {
1724
+ if (
1725
+ importedPath !== state.path &&
1726
+ (isStylesheetModule(importedPath) || isPreprocessorPath(importedPath))
1727
+ ) {
821
1728
  addStyleDependent(state.styleDependents, importedPath, state.path);
822
1729
  }
823
1730
  await collectHtmlStyleContexts({
@@ -832,13 +1739,14 @@ async function collectHtmlStyleContexts(state) {
832
1739
  }
833
1740
 
834
1741
  function inferredPreprocessorPath(state) {
835
- const stem = basename(state.path, '.css');
836
- const matches = [...state.styleSources.keys()].filter((path) =>
837
- isPreprocessorPath(path)
838
- && state.pathOwners.get(path) === state.packageRoot
839
- && !basename(path).startsWith('_')
840
- && !state.styleDependents.has(path)
841
- && basename(path, extension(path)) === stem,
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,
842
1750
  );
843
1751
  return matches.length === 1 ? matches[0] : undefined;
844
1752
  }
@@ -847,9 +1755,12 @@ function addInferredPreprocessorContext(state) {
847
1755
  // A source importing the generated CSS pins the artifact itself: excluding
848
1756
  // it from planning while migrating the inferred entry could delete the only
849
1757
  // source able to rebuild the file that import depends on.
850
- if (state.styleSources.has(state.path)
851
- && state.sourceFiles.some((file) =>
852
- extension(file.path) !== '.html' && sourceReferencesStyle(file, state.path))) {
1758
+ if (
1759
+ state.styleSources.has(state.path) &&
1760
+ state.sourceFiles.some(
1761
+ (file) => extension(file.path) !== ".html" && sourceReferencesStyle(file, state.path),
1762
+ )
1763
+ ) {
853
1764
  return false;
854
1765
  }
855
1766
  const path = inferredPreprocessorPath(state);
@@ -862,13 +1773,15 @@ function addInferredPreprocessorContext(state) {
862
1773
  direct: state.direct,
863
1774
  analyzable: true,
864
1775
  });
865
- state.warnings.push(htmlWarning(
866
- 'inferred-preprocessor-source',
867
- state.path,
868
- 0,
869
- 0,
870
- `The linked CSS was matched to the unique preprocessor filename ${basename(path)}.`,
871
- ));
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
+ );
872
1785
  return true;
873
1786
  }
874
1787
 
@@ -883,7 +1796,7 @@ function cssImports(source) {
883
1796
  for (let index = 0; index < masked.length; index += 1) {
884
1797
  const character = masked[index];
885
1798
  if (quote) {
886
- if (character === '\\') index += 1;
1799
+ if (character === "\\") index += 1;
887
1800
  else if (character === quote) quote = undefined;
888
1801
  continue;
889
1802
  }
@@ -891,18 +1804,22 @@ function cssImports(source) {
891
1804
  quote = character;
892
1805
  continue;
893
1806
  }
894
- if (character === '{') {
1807
+ if (character === "{") {
895
1808
  depth += 1;
896
1809
  importsAllowed = false;
897
1810
  continue;
898
1811
  }
899
- if (character === '}') {
1812
+ if (character === "}") {
900
1813
  depth = Math.max(0, depth - 1);
901
1814
  continue;
902
1815
  }
903
- if (!importsAllowed || depth !== 0
904
- || masked.slice(index, index + 7).toLowerCase() !== '@import'
905
- || /[-\w]/.test(masked[index + 7] ?? '')) continue;
1816
+ if (
1817
+ !importsAllowed ||
1818
+ depth !== 0 ||
1819
+ masked.slice(index, index + 7).toLowerCase() !== "@import" ||
1820
+ /[-\w]/.test(masked[index + 7] ?? "")
1821
+ )
1822
+ continue;
906
1823
 
907
1824
  let end = index + 7;
908
1825
  let importQuote;
@@ -910,52 +1827,64 @@ function cssImports(source) {
910
1827
  for (; end < masked.length; end += 1) {
911
1828
  const next = masked[end];
912
1829
  if (importQuote) {
913
- if (next === '\\') end += 1;
1830
+ if (next === "\\") end += 1;
914
1831
  else if (next === importQuote) importQuote = undefined;
915
1832
  } else if (next === '"' || next === "'") importQuote = next;
916
- else if (next === '(') parentheses += 1;
917
- else if (next === ')') parentheses = Math.max(0, parentheses - 1);
918
- else if (next === ';' && parentheses === 0) break;
1833
+ else if (next === "(") parentheses += 1;
1834
+ else if (next === ")") parentheses = Math.max(0, parentheses - 1);
1835
+ else if (next === ";" && parentheses === 0) break;
919
1836
  }
920
1837
  if (end >= masked.length) continue;
921
1838
  const statement = masked.slice(index, end + 1);
922
- const match = /^@import\s+(?:url\(\s*)?(?:["']([^"']+)["']|([^"'()\s;]+))\s*\)?\s*([^;]*);$/i.exec(statement);
923
- if (match) imports.push({
924
- href: match[1] ?? match[2],
925
- media: match[3].trim(),
926
- start: utf8Offset(source, index),
927
- end: utf8Offset(source, end + 1),
928
- });
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
+ });
929
1850
  index = end;
930
1851
  }
931
- return imports;
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
+ }));
932
1863
  }
933
1864
 
934
1865
  function localHtmlReference(packageRoot, base, reference) {
935
1866
  const path = reference.split(/[?#]/, 1)[0];
936
- if (!path || path.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(path)) return undefined;
1867
+ if (!path || path.startsWith("//") || /^[a-z][a-z\d+.-]*:/i.test(path)) return undefined;
937
1868
  let decoded;
938
1869
  try {
939
1870
  decoded = decodeURIComponent(path);
940
1871
  } catch {
941
1872
  return undefined;
942
1873
  }
943
- return decoded.startsWith('/')
944
- ? resolve(packageRoot, `.${decoded}`)
945
- : resolve(base, decoded);
1874
+ return decoded.startsWith("/") ? resolve(packageRoot, `.${decoded}`) : resolve(base, decoded);
946
1875
  }
947
1876
 
948
1877
  function mediaVariants(media) {
949
1878
  const normalized = media.trim().toLowerCase();
950
- if (!normalized || normalized === 'all') return [];
951
- if (normalized === 'print') return ['print'];
1879
+ if (!normalized || normalized === "all") return [];
1880
+ if (normalized === "print") return ["print"];
952
1881
  return undefined;
953
1882
  }
954
1883
 
955
1884
  function deduplicateHtmlContexts(contexts) {
956
1885
  const unique = new Map();
957
1886
  for (const context of contexts) {
958
- const key = `${context.cssPath}\0${context.variants.join(':')}\0${context.analyzable}`;
1887
+ const key = `${context.cssPath}\0${context.variants.join(":")}\0${context.analyzable}`;
959
1888
  const existing = unique.get(key);
960
1889
  if (existing) existing.direct ||= context.direct;
961
1890
  else unique.set(key, { ...context });
@@ -968,8 +1897,10 @@ function htmlWarning(code, file, start, end, message) {
968
1897
  }
969
1898
 
970
1899
  function isProjectInput(workspaceRoot, path) {
971
- return isWithin(workspaceRoot, path)
972
- && !relative(workspaceRoot, path).split(/[\\/]/).includes('node_modules');
1900
+ return (
1901
+ isWithin(workspaceRoot, path) &&
1902
+ !relative(workspaceRoot, path).split(/[\\/]/).includes("node_modules")
1903
+ );
973
1904
  }
974
1905
 
975
1906
  async function rejectSymlinkTarget(path, root) {
@@ -982,7 +1913,21 @@ async function rejectSymlinkTarget(path, root) {
982
1913
  }
983
1914
 
984
1915
  async function snapshotFile(snapshots, path) {
985
- return recordSnapshot(snapshots, path, await readFile(path, 'utf8'));
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
+ }
986
1931
  }
987
1932
 
988
1933
  function recordSnapshot(snapshots, path, source) {
@@ -996,7 +1941,7 @@ function recordSnapshot(snapshots, path, source) {
996
1941
  function packageFailure(workspaceRoot, packageRoot, error) {
997
1942
  const message = error instanceof Error ? error.message : String(error);
998
1943
  return {
999
- package: normalizedRelativePath(workspaceRoot, packageRoot) || '.',
1944
+ package: normalizedRelativePath(workspaceRoot, packageRoot) || ".",
1000
1945
  message: message.startsWith(RECOVERABLE_INPUT_ERROR)
1001
1946
  ? message.slice(RECOVERABLE_INPUT_ERROR.length)
1002
1947
  : message,
@@ -1008,13 +1953,20 @@ function isRecoverablePlanningError(error) {
1008
1953
  return message.startsWith(RECOVERABLE_INPUT_ERROR);
1009
1954
  }
1010
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
+
1011
1961
  function isIntegrityError(error) {
1012
1962
  const message = error instanceof Error ? error.message : String(error);
1013
- return message.startsWith('Source changed during planning:');
1963
+ return message.startsWith("Source changed during planning:");
1014
1964
  }
1015
1965
 
1016
1966
  async function readSources(paths, snapshots) {
1017
- return new Map(await Promise.all(paths.map(async (path) => [path, await snapshotFile(snapshots, path)])));
1967
+ return new Map(
1968
+ await Promise.all(paths.map(async (path) => [path, await snapshotFile(snapshots, path)])),
1969
+ );
1018
1970
  }
1019
1971
 
1020
1972
  async function collectFiles(root, include) {
@@ -1036,20 +1988,16 @@ async function collectFiles(root, include) {
1036
1988
  }
1037
1989
 
1038
1990
  function extension(path) {
1039
- const match = /\.[^.\/]+$/.exec(path);
1040
- return match?.[0] ?? '';
1991
+ const match = /\.[^./]+$/.exec(path);
1992
+ return match?.[0] ?? "";
1041
1993
  }
1042
1994
 
1043
1995
  function normalizedRelativePath(root, path) {
1044
- return relative(root, path).split(sep).join('/');
1996
+ return relative(root, path).split(sep).join("/");
1045
1997
  }
1046
1998
 
1047
1999
  function maskCssComments(source) {
1048
- return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\r\n]/g, ' '));
1049
- }
1050
-
1051
- function utf8Offset(source, index) {
1052
- return Buffer.byteLength(source.slice(0, index));
2000
+ return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\r\n]/g, " "));
1053
2001
  }
1054
2002
 
1055
2003
  function indexStylesheetDependents(styleSources) {
@@ -1057,26 +2005,30 @@ function indexStylesheetDependents(styleSources) {
1057
2005
  for (const [path, rawSource] of styleSources) {
1058
2006
  const source = maskCssComments(rawSource);
1059
2007
  const references = [
1060
- ...[...source.matchAll(/composes\s*:[^;{}]*?\bfrom\s+["']([^"']+)["']/g)]
1061
- .map((match) => match[1]),
1062
- ...[...source.matchAll(/@(?:use|forward)\s+["']([^"']+)["']/g)]
1063
- .map((match) => match[1]),
1064
- ...[...source.matchAll(/@import\s+(?:\([^)]*\)\s*)?(?:([^;{}]+);|([^;{}\r\n]+))/g)]
1065
- .flatMap((statement) => [...(statement[1] ?? statement[2]).matchAll(/["']([^"']+)["']/g)]
1066
- .map((match) => match[1])),
1067
- ...[...source.matchAll(/@import\s+url\(\s*([^"'()\s]+)\s*\)/g)]
1068
- .map((match) => match[1]),
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]),
1069
2019
  ];
1070
2020
  for (const reference of new Set(references)) {
1071
2021
  for (const target of stylesheetReferenceTargets(path, reference, styleSources)) {
1072
- if (target === path || (!isStylesheetModule(target) && !isPreprocessorPath(target))) continue;
2022
+ if (target === path || (!isStylesheetModule(target) && !isPreprocessorPath(target)))
2023
+ continue;
1073
2024
  const paths = dependents.get(target) ?? [];
1074
2025
  paths.push(path);
1075
2026
  dependents.set(target, paths);
1076
2027
  }
1077
2028
  }
1078
2029
  }
1079
- for (const [target, paths] of dependents) dependents.set(target, [...new Set(paths)].sort());
2030
+ for (const [target, paths] of dependents)
2031
+ dependents.set(target, [...new Set(paths)].sort(compareStrings));
1080
2032
  return dependents;
1081
2033
  }
1082
2034
 
@@ -1084,7 +2036,7 @@ function addStyleDependent(styleDependents, target, importer) {
1084
2036
  const paths = styleDependents.get(target) ?? [];
1085
2037
  if (paths.includes(importer)) return;
1086
2038
  paths.push(importer);
1087
- paths.sort();
2039
+ paths.sort(compareStrings);
1088
2040
  styleDependents.set(target, paths);
1089
2041
  }
1090
2042
 
@@ -1093,45 +2045,54 @@ function stylesheetReferenceTargets(importer, reference, styleSources) {
1093
2045
  const candidates = STYLESHEET_SYNTAX.has(extension(target))
1094
2046
  ? [target]
1095
2047
  : [...STYLESHEET_SYNTAX.keys()].flatMap((syntax) => [
1096
- `${target}${syntax}`,
1097
- join(dirname(target), `_${basename(target)}${syntax}`),
1098
- join(target, `_index${syntax}`),
1099
- join(target, `index${syntax}`),
1100
- ]);
2048
+ `${target}${syntax}`,
2049
+ join(dirname(target), `_${basename(target)}${syntax}`),
2050
+ join(target, `_index${syntax}`),
2051
+ join(target, `index${syntax}`),
2052
+ ]);
1101
2053
  return candidates.filter((path) => styleSources.has(path));
1102
2054
  }
1103
2055
 
1104
2056
  function resolveTailwindEntry(stylePaths, styleSources, configuredPath) {
1105
2057
  const entries = stylePaths.filter((path) => {
1106
- if (extension(path) !== '.css') return false;
2058
+ if (extension(path) !== ".css") return false;
1107
2059
  const source = maskCssComments(styleSources.get(path));
1108
2060
  return /@import\s+["']tailwindcss(?:\/[^"']*)?["']/.test(source);
1109
2061
  });
1110
2062
  if (configuredPath) return { path: configuredPath, entries };
1111
- if (entries.length === 0) throw new Error('No Tailwind v4 CSS entry was found. Pass --tailwind-css.');
1112
- if (entries.length > 1) throw new Error('Multiple Tailwind CSS entries were found. Pass --tailwind-css.');
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.");
1113
2067
  return { path: entries[0], entries };
1114
2068
  }
1115
2069
 
1116
2070
  async function loadTailwind(packageRoot, tailwindCss, snapshots, workspaceRoot) {
1117
- const projectRequire = createRequire(join(packageRoot, 'package.json'));
2071
+ const projectRequire = createRequire(join(packageRoot, "package.json"));
1118
2072
  let packagePath;
1119
2073
  try {
1120
- packagePath = projectRequire.resolve('tailwindcss/package.json');
2074
+ packagePath = projectRequire.resolve("tailwindcss/package.json");
1121
2075
  } catch {
1122
- throw new Error('Tailwind v4 must be installed in the target project.');
2076
+ throw new Error("Tailwind v4 must be installed in the target project.");
1123
2077
  }
1124
- const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
1125
- if (!String(packageJson.version).startsWith('4.')) throw new Error(`Tailwind v4 is required; found ${packageJson.version}.`);
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}.`);
1126
2081
 
1127
- const modulePath = projectRequire.resolve('tailwindcss');
2082
+ const modulePath = projectRequire.resolve("tailwindcss");
1128
2083
  const tailwindModule = await import(pathToFileURL(modulePath));
1129
- const { __unstable__loadDesignSystem: loadDesignSystem } = tailwindModule.default ?? tailwindModule;
2084
+ const { __unstable__loadDesignSystem: loadDesignSystem } =
2085
+ tailwindModule.default ?? tailwindModule;
1130
2086
  const css = await snapshotFile(snapshots, tailwindCss);
1131
2087
  const base = dirname(tailwindCss);
1132
2088
  const loadModule = createModuleLoader(snapshots, workspaceRoot);
1133
- const loadStylesheet = createStylesheetLoader(projectRequire, packagePath, snapshots, workspaceRoot);
1134
- const defaultTheme = await readFile(join(dirname(packagePath), 'theme.css'), 'utf8');
2089
+ const loadStylesheet = createStylesheetLoader(
2090
+ projectRequire,
2091
+ packagePath,
2092
+ snapshots,
2093
+ workspaceRoot,
2094
+ );
2095
+ const defaultTheme = await readFile(join(dirname(packagePath), "theme.css"), "utf8");
1135
2096
  const themeTokens = {
1136
2097
  ...extractThemeTokens(defaultTheme),
1137
2098
  ...(await extractThemeTokensFromGraph(css, base, loadStylesheet)),
@@ -1142,8 +2103,9 @@ async function loadTailwind(packageRoot, tailwindCss, snapshots, workspaceRoot)
1142
2103
 
1143
2104
  function extractThemeTokens(css) {
1144
2105
  const tokens = {};
1145
- for (const block of css.matchAll(/@theme[^\{]*\{([^}]*)\}/gs)) {
1146
- for (const match of block[1].matchAll(/--([\w-]+):\s*([^;{}]+);/g)) tokens[match[1]] = match[2].trim();
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();
1147
2109
  }
1148
2110
  return tokens;
1149
2111
  }
@@ -1155,7 +2117,10 @@ async function extractThemeTokensFromGraph(css, base, loadStylesheet, seen = new
1155
2117
  if (seen.has(key)) continue;
1156
2118
  seen.add(key);
1157
2119
  const loaded = await loadStylesheet(match[1], base);
1158
- Object.assign(tokens, await extractThemeTokensFromGraph(loaded.content, loaded.base, loadStylesheet, seen));
2120
+ Object.assign(
2121
+ tokens,
2122
+ await extractThemeTokensFromGraph(loaded.content, loaded.base, loadStylesheet, seen),
2123
+ );
1159
2124
  }
1160
2125
  return Object.assign(tokens, extractThemeTokens(css));
1161
2126
  }
@@ -1167,7 +2132,7 @@ function invalidCandidates(tailwind, candidates) {
1167
2132
 
1168
2133
  function createModuleLoader(snapshots, workspaceRoot) {
1169
2134
  return async (id, base) => {
1170
- const path = createRequire(join(base, 'package.json')).resolve(id);
2135
+ const path = createRequire(join(base, "package.json")).resolve(id);
1171
2136
  if (isProjectInput(workspaceRoot, path)) await snapshotFile(snapshots, path);
1172
2137
  const imported = await import(pathToFileURL(path));
1173
2138
  return { path, base: dirname(path), module: imported.default ?? imported };
@@ -1178,36 +2143,42 @@ function createStylesheetLoader(projectRequire, tailwindPackagePath, snapshots,
1178
2143
  const tailwindRoot = dirname(tailwindPackagePath);
1179
2144
  return async (id, base) => {
1180
2145
  let path;
1181
- if (id === 'tailwindcss') path = join(tailwindRoot, 'index.css');
1182
- else if (id.startsWith('tailwindcss/')) {
1183
- const subpath = id.slice('tailwindcss/'.length);
1184
- path = join(tailwindRoot, subpath.endsWith('.css') ? subpath : `${subpath}.css`);
1185
- } else if (id.startsWith('.') || isAbsolute(id)) path = resolve(base, id);
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);
1186
2151
  else path = projectRequire.resolve(id);
1187
2152
  const content = isProjectInput(workspaceRoot, path)
1188
2153
  ? await snapshotFile(snapshots, path)
1189
- : await readFile(path, 'utf8');
2154
+ : await readFile(path, "utf8");
1190
2155
  return { content, base: dirname(path) };
1191
2156
  };
1192
2157
  }
1193
2158
 
1194
2159
  function unifiedDiff(path, before, after) {
1195
- const oldLines = before.split('\n');
1196
- const newLines = after.split('\n');
2160
+ const oldLines = before.split("\n");
2161
+ const newLines = after.split("\n");
1197
2162
  return [
1198
2163
  `--- a/${path}\n`,
1199
2164
  `+++ b/${path}\n`,
1200
2165
  `@@ -1,${oldLines.length} +1,${newLines.length} @@\n`,
1201
- ...oldLines.map((line, index) => `${index === oldLines.length - 1 && line === '' ? ' ' : '-'}${line}\n`),
1202
- ...newLines.map((line, index) => `${index === newLines.length - 1 && line === '' ? ' ' : '+'}${line}\n`),
1203
- ].join('');
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("");
1204
2173
  }
1205
2174
 
1206
2175
  async function verifySnapshots(snapshots) {
1207
- for (const [path, before] of [...snapshots].sort(([left], [right]) => left.localeCompare(right))) {
2176
+ for (const [path, before] of [...snapshots].sort(([left], [right]) =>
2177
+ left.localeCompare(right),
2178
+ )) {
1208
2179
  let current;
1209
2180
  try {
1210
- current = await readFile(path, 'utf8');
2181
+ current = await readFile(path, "utf8");
1211
2182
  } catch (error) {
1212
2183
  throw new Error(`Source changed after planning: ${path} (${error.code ?? error.message})`);
1213
2184
  }