tw-migrate 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/tw-migrate.js +46 -0
- package/html.js +153 -0
- package/index.d.ts +46 -0
- package/index.js +1254 -0
- package/native.js +34 -0
- package/package.json +90 -0
- package/rfcs/batch-css-migration.md +297 -0
- package/rfcs/browser-ecosystem-e2e.md +338 -0
- package/rfcs/css-to-tailwind-migration-cli.md +521 -0
- package/rfcs/preprocessor-and-html-migration.md +497 -0
- package/style-compiler.js +125 -0
package/index.js
ADDED
|
@@ -0,0 +1,1254 @@
|
|
|
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';
|
|
10
|
+
import {
|
|
11
|
+
compileLessEntry,
|
|
12
|
+
compileSassEntry,
|
|
13
|
+
isPreprocessorPath,
|
|
14
|
+
isSassPath,
|
|
15
|
+
loadProjectLess,
|
|
16
|
+
loadProjectSass,
|
|
17
|
+
} from './style-compiler.js';
|
|
18
|
+
|
|
19
|
+
const run = promisify(execFile);
|
|
20
|
+
const SOURCE_EXTENSIONS = new Set(['.html', '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts']);
|
|
21
|
+
const STYLESHEET_SYNTAX = new Map([
|
|
22
|
+
['.css', 'css'],
|
|
23
|
+
['.scss', 'scss'],
|
|
24
|
+
['.sass', 'sass'],
|
|
25
|
+
['.less', 'less'],
|
|
26
|
+
]);
|
|
27
|
+
const IGNORED_DIRECTORIES = new Set(['.git', '.next', 'build', 'dist', 'node_modules']);
|
|
28
|
+
const RECOVERABLE_INPUT_ERROR = 'TW_MIGRATE_RECOVERABLE_INPUT:';
|
|
29
|
+
|
|
30
|
+
export async function migrate(options = {}) {
|
|
31
|
+
if ('cssFile' in options) {
|
|
32
|
+
throw new TypeError('cssFile has been replaced by styleFile');
|
|
33
|
+
}
|
|
34
|
+
if (options.styleFile && options.workspaces) {
|
|
35
|
+
throw new TypeError('styleFile cannot be combined with workspaces');
|
|
36
|
+
}
|
|
37
|
+
if (options.tailwindCss && options.workspaces) {
|
|
38
|
+
throw new TypeError('tailwindCss cannot be combined with workspaces');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const scope = await resolveScope(options);
|
|
42
|
+
const { cwd, workspaceRoot, selectedPackages, explicitStyle, configuredEntry } = scope;
|
|
43
|
+
|
|
44
|
+
// A prior interrupted run's scope is unknowable from the current flags
|
|
45
|
+
// (it may have covered other packages), so scan the whole workspace root.
|
|
46
|
+
const leftovers = await collectFiles(workspaceRoot, (path) => basename(path).includes('.tw-migrate-'));
|
|
47
|
+
if (leftovers.length > 0) {
|
|
48
|
+
const listed = leftovers.sort().map((path) => ` ${normalizedRelativePath(cwd, path)}`).join('\n');
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Found leftover tw-migrate files from an interrupted run:\n${listed}\n` +
|
|
51
|
+
'Restore each ".<name>.tw-migrate-backup-*" file by renaming it back to "<name>", ' +
|
|
52
|
+
'delete any remaining ".<name>.tw-migrate-*" staging files, then re-run.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const snapshots = new Map();
|
|
57
|
+
const stylePaths = scope.scannedPaths.filter(isStylesheetPath);
|
|
58
|
+
const sourcePaths = scope.scannedPaths.filter((path) => SOURCE_EXTENSIONS.has(extension(path)));
|
|
59
|
+
const [styleSources, sourceCandidates] = await Promise.all([
|
|
60
|
+
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'))),
|
|
74
|
+
]);
|
|
75
|
+
const sourceFiles = sourceCandidates.filter(Boolean);
|
|
76
|
+
if (explicitStyle && !styleSources.has(explicitStyle)) {
|
|
77
|
+
styleSources.set(explicitStyle, await snapshotFile(snapshots, explicitStyle));
|
|
78
|
+
}
|
|
79
|
+
if (configuredEntry && !styleSources.has(configuredEntry)) {
|
|
80
|
+
styleSources.set(configuredEntry, await snapshotFile(snapshots, configuredEntry));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const context = {
|
|
84
|
+
...scope,
|
|
85
|
+
options,
|
|
86
|
+
snapshots,
|
|
87
|
+
styleSources,
|
|
88
|
+
sourceFiles,
|
|
89
|
+
styleDependents: indexStylesheetDependents(styleSources),
|
|
90
|
+
};
|
|
91
|
+
const failures = [];
|
|
92
|
+
const plans = [];
|
|
93
|
+
for (const packageRoot of selectedPackages) {
|
|
94
|
+
const result = await planPackage(context, packageRoot);
|
|
95
|
+
if (result.failure) failures.push(result.failure);
|
|
96
|
+
else if (result.plan) plans.push(result.plan);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const originals = new Map([
|
|
100
|
+
...styleSources,
|
|
101
|
+
...sourceFiles.map((file) => [file.path, file.source]),
|
|
102
|
+
]);
|
|
103
|
+
const { filesByPath, deletedPaths, candidates, rules, warnings, convertedRules, retainedRules } =
|
|
104
|
+
mergePlans(plans, originals);
|
|
105
|
+
|
|
106
|
+
const changed = [...filesByPath.values()]
|
|
107
|
+
.map((file) => ({ ...file, before: originals.get(file.path) }))
|
|
108
|
+
.filter((file) => file.before !== file.source)
|
|
109
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
110
|
+
const deleted = [...deletedPaths]
|
|
111
|
+
.map((path) => ({ path, before: originals.get(path) }))
|
|
112
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
113
|
+
const operations = [...changed, ...deleted].sort((left, right) => left.path.localeCompare(right.path));
|
|
114
|
+
const changedFiles = operations.map((file) => normalizedRelativePath(cwd, file.path));
|
|
115
|
+
const diff = operations
|
|
116
|
+
.map((file) => unifiedDiff(normalizedRelativePath(cwd, file.path), file.before, 'source' in file ? file.source : ''))
|
|
117
|
+
.join('');
|
|
118
|
+
|
|
119
|
+
if (options.write && operations.length > 0) {
|
|
120
|
+
await verifySnapshots(snapshots);
|
|
121
|
+
await writeChanges(changed, deleted);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
warnings.sort((left, right) =>
|
|
125
|
+
left.file.localeCompare(right.file) || left.start - right.start || left.end - right.end || left.code.localeCompare(right.code));
|
|
126
|
+
failures.sort((left, right) => left.package.localeCompare(right.package));
|
|
127
|
+
return {
|
|
128
|
+
changedFiles,
|
|
129
|
+
diff,
|
|
130
|
+
convertedRules,
|
|
131
|
+
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) })),
|
|
135
|
+
failures,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function resolveScope(options) {
|
|
140
|
+
const cwd = await realpath(resolve(options.cwd ?? process.cwd()));
|
|
141
|
+
const currentPackage = await findPackageRoot(cwd);
|
|
142
|
+
const gitRoot = await findGitRoot(currentPackage);
|
|
143
|
+
const workspaceRoot = gitRoot && !(await isIgnoredByGit(gitRoot, currentPackage))
|
|
144
|
+
? gitRoot
|
|
145
|
+
: currentPackage;
|
|
146
|
+
const allPaths = await discoverFiles(workspaceRoot, workspaceRoot === gitRoot);
|
|
147
|
+
// Ignore filtering scopes what gets migrated, never what gets scanned:
|
|
148
|
+
// gitignored consumers and stylesheets must still block unsafe deletion.
|
|
149
|
+
const scannedPaths = workspaceRoot === gitRoot
|
|
150
|
+
? [...new Set([...allPaths, ...(await collectFiles(workspaceRoot, isRelevantDiscoveredFile))])]
|
|
151
|
+
: [...allPaths];
|
|
152
|
+
const explicitStyle = options.styleFile ? resolve(cwd, options.styleFile) : undefined;
|
|
153
|
+
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');
|
|
156
|
+
}
|
|
157
|
+
if (configuredEntry && extension(configuredEntry) !== '.css') {
|
|
158
|
+
throw new TypeError('The Tailwind CSS entry must be a .css file');
|
|
159
|
+
}
|
|
160
|
+
if (configuredEntry && !(await stat(configuredEntry)).isFile()) {
|
|
161
|
+
throw new TypeError('The Tailwind CSS entry must be a file');
|
|
162
|
+
}
|
|
163
|
+
for (const path of [explicitStyle, configuredEntry]) {
|
|
164
|
+
if (path) await rejectSymlinkTarget(path, currentPackage);
|
|
165
|
+
}
|
|
166
|
+
if (explicitStyle && !isWithin(currentPackage, explicitStyle)) {
|
|
167
|
+
throw new TypeError('The selected stylesheet must belong to the current package');
|
|
168
|
+
}
|
|
169
|
+
for (const path of [explicitStyle, configuredEntry]) {
|
|
170
|
+
if (path && !allPaths.includes(path)) allPaths.push(path);
|
|
171
|
+
if (path && !scannedPaths.includes(path)) scannedPaths.push(path);
|
|
172
|
+
}
|
|
173
|
+
allPaths.sort();
|
|
174
|
+
scannedPaths.sort();
|
|
175
|
+
const targetable = new Set(allPaths);
|
|
176
|
+
|
|
177
|
+
const allPackageRoots = await discoverPackageRoots(workspaceRoot, allPaths);
|
|
178
|
+
if (!allPackageRoots.includes(currentPackage)) allPackageRoots.push(currentPackage);
|
|
179
|
+
for (const path of [explicitStyle, configuredEntry]) {
|
|
180
|
+
if (!path) continue;
|
|
181
|
+
const owner = await findPackageRoot(dirname(path));
|
|
182
|
+
if (!allPackageRoots.includes(owner)) allPackageRoots.push(owner);
|
|
183
|
+
}
|
|
184
|
+
allPackageRoots.sort();
|
|
185
|
+
const pathOwners = new Map(scannedPaths.map((path) => [path, owningPackage(path, allPackageRoots)]));
|
|
186
|
+
if (explicitStyle && pathOwners.get(explicitStyle) !== currentPackage) {
|
|
187
|
+
throw new TypeError('The selected stylesheet must belong to the current package');
|
|
188
|
+
}
|
|
189
|
+
if (configuredEntry && pathOwners.get(configuredEntry) !== currentPackage) {
|
|
190
|
+
throw new TypeError('The Tailwind CSS entry must belong to the current package');
|
|
191
|
+
}
|
|
192
|
+
const selectedPackages = options.workspaces ? allPackageRoots : [currentPackage];
|
|
193
|
+
return {
|
|
194
|
+
cwd,
|
|
195
|
+
workspaceRoot,
|
|
196
|
+
scannedPaths,
|
|
197
|
+
targetable,
|
|
198
|
+
explicitStyle,
|
|
199
|
+
configuredEntry,
|
|
200
|
+
pathOwners,
|
|
201
|
+
selectedPackages,
|
|
202
|
+
writablePackages: new Set(selectedPackages),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function planPackage(context, packageRoot) {
|
|
207
|
+
const {
|
|
208
|
+
options,
|
|
209
|
+
snapshots,
|
|
210
|
+
workspaceRoot,
|
|
211
|
+
explicitStyle,
|
|
212
|
+
configuredEntry,
|
|
213
|
+
styleSources,
|
|
214
|
+
sourceFiles,
|
|
215
|
+
styleDependents,
|
|
216
|
+
pathOwners,
|
|
217
|
+
targetable,
|
|
218
|
+
writablePackages,
|
|
219
|
+
} = context;
|
|
220
|
+
let preparedHtml;
|
|
221
|
+
try {
|
|
222
|
+
preparedHtml = await preparePackageHtml({
|
|
223
|
+
packageRoot,
|
|
224
|
+
sourceFiles,
|
|
225
|
+
styleSources,
|
|
226
|
+
snapshots,
|
|
227
|
+
pathOwners,
|
|
228
|
+
styleDependents,
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (!options.force || isIntegrityError(error)) throw error;
|
|
232
|
+
return { failure: packageFailure(workspaceRoot, packageRoot, error) };
|
|
233
|
+
}
|
|
234
|
+
const packageSources = [
|
|
235
|
+
...sourceFiles.filter((file) => extension(file.path) !== '.html'),
|
|
236
|
+
...preparedHtml.files,
|
|
237
|
+
];
|
|
238
|
+
const ownedStyles = [...styleSources.keys()].filter(
|
|
239
|
+
(path) => pathOwners.get(path) === packageRoot
|
|
240
|
+
&& (targetable.has(path) || preparedHtml.stylePaths.has(path)),
|
|
241
|
+
);
|
|
242
|
+
if (ownedStyles.length === 0) return {};
|
|
243
|
+
|
|
244
|
+
let tailwindPath;
|
|
245
|
+
let tailwindEntries;
|
|
246
|
+
try {
|
|
247
|
+
({ path: tailwindPath, entries: tailwindEntries } = resolveTailwindEntry(
|
|
248
|
+
ownedStyles,
|
|
249
|
+
styleSources,
|
|
250
|
+
configuredEntry,
|
|
251
|
+
));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!options.force) throw error;
|
|
254
|
+
return { failure: packageFailure(workspaceRoot, packageRoot, error) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
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 {};
|
|
268
|
+
if (targets.some((path) => excludedEntries.has(path))) {
|
|
269
|
+
throw new Error('The Tailwind CSS entry cannot be migrated.');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let tailwind;
|
|
273
|
+
try {
|
|
274
|
+
tailwind = await loadTailwind(packageRoot, tailwindPath, snapshots, workspaceRoot);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!options.force || isIntegrityError(error)) throw error;
|
|
277
|
+
return { failure: packageFailure(workspaceRoot, packageRoot, error) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const files = packageSources.map((file) => {
|
|
281
|
+
const owner = pathOwners.get(file.path);
|
|
282
|
+
return {
|
|
283
|
+
...file,
|
|
284
|
+
writable: targetable.has(file.path)
|
|
285
|
+
&& (options.workspaces ? writablePackages.has(owner) : owner === packageRoot),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
let stylesheets;
|
|
289
|
+
let sass;
|
|
290
|
+
let less;
|
|
291
|
+
try {
|
|
292
|
+
stylesheets = [];
|
|
293
|
+
const compilerDependents = new Map();
|
|
294
|
+
for (const stylePath of targets.sort()) {
|
|
295
|
+
await rejectSymlinkTarget(stylePath, packageRoot);
|
|
296
|
+
const isPartial = isSassPath(stylePath) && basename(stylePath).startsWith('_');
|
|
297
|
+
const stylesheet = {
|
|
298
|
+
cssPath: stylePath,
|
|
299
|
+
cssSource: styleSources.get(stylePath),
|
|
300
|
+
cssModuleId: normalizedRelativePath(packageRoot, stylePath),
|
|
301
|
+
cssDependents: styleDependents.get(stylePath) ?? [],
|
|
302
|
+
syntax: stylesheetSyntax(stylePath),
|
|
303
|
+
isModule: isStylesheetModule(stylePath),
|
|
304
|
+
isPartial,
|
|
305
|
+
};
|
|
306
|
+
let compiled;
|
|
307
|
+
if (isSassPath(stylePath) && !isPartial) {
|
|
308
|
+
sass ??= await loadProjectSass(packageRoot);
|
|
309
|
+
// Compile the snapshotted source, not the on-disk file: code loaded
|
|
310
|
+
// during planning (e.g. Tailwind plugins) may have rewritten it since.
|
|
311
|
+
compiled = await compileSassEntry(sass, stylePath, stylesheet.cssSource);
|
|
312
|
+
} else if (extension(stylePath) === '.less') {
|
|
313
|
+
less ??= await loadProjectLess(packageRoot);
|
|
314
|
+
compiled = await compileLessEntry(less, stylePath, stylesheet.cssSource);
|
|
315
|
+
}
|
|
316
|
+
if (compiled) {
|
|
317
|
+
validateCss(compiled.css);
|
|
318
|
+
for (const loadedPath of compiled.loadedPaths) {
|
|
319
|
+
if (!isProjectInput(workspaceRoot, loadedPath)) continue;
|
|
320
|
+
const source = await snapshotFile(snapshots, loadedPath);
|
|
321
|
+
if (!styleSources.has(loadedPath)) styleSources.set(loadedPath, source);
|
|
322
|
+
if (loadedPath !== stylePath) {
|
|
323
|
+
const dependents = compilerDependents.get(loadedPath) ?? [];
|
|
324
|
+
dependents.push(stylePath);
|
|
325
|
+
compilerDependents.set(loadedPath, dependents);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
stylesheet.analysisSource = compiled.css;
|
|
329
|
+
stylesheet.sourceMappings = compiled.sourceMappings;
|
|
330
|
+
}
|
|
331
|
+
stylesheets.push(stylesheet);
|
|
332
|
+
}
|
|
333
|
+
for (const stylesheet of stylesheets) {
|
|
334
|
+
const dependents = compilerDependents.get(stylesheet.cssPath) ?? [];
|
|
335
|
+
if (dependents.length === 0) continue;
|
|
336
|
+
stylesheet.isPartial = true;
|
|
337
|
+
stylesheet.cssDependents = [...new Set([...stylesheet.cssDependents, ...dependents])].sort();
|
|
338
|
+
}
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (!options.force || isIntegrityError(error)) throw error;
|
|
341
|
+
return { failure: packageFailure(workspaceRoot, packageRoot, error) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const request = {
|
|
345
|
+
stylesheets,
|
|
346
|
+
tailwindPath: tailwind.path,
|
|
347
|
+
tailwindSource: tailwind.css,
|
|
348
|
+
utilityPrefix: tailwind.designSystem.theme.prefix,
|
|
349
|
+
themeTokens: tailwind.themeTokens,
|
|
350
|
+
files,
|
|
351
|
+
};
|
|
352
|
+
let plan;
|
|
353
|
+
try {
|
|
354
|
+
plan = JSON.parse(planBatchMigration(JSON.stringify(request)));
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (!options.force || !isRecoverablePlanningError(error)) throw error;
|
|
357
|
+
return { failure: packageFailure(workspaceRoot, packageRoot, error) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
plan = replanCompileFailures(tailwind, request, plan);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (!options.force) throw error;
|
|
364
|
+
return { failure: packageFailure(workspaceRoot, packageRoot, error) };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
removeMigratedHtmlLinks(plan, preparedHtml);
|
|
368
|
+
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);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { plan };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// A candidate Tailwind refuses to compile retains its owning rule(s) instead
|
|
386
|
+
// of aborting the run: block those rules and replan until every applied
|
|
387
|
+
// candidate compiles. Each iteration blocks at least one new rule, so the
|
|
388
|
+
// loop is bounded by the rule count; if a failing candidate cannot be
|
|
389
|
+
// attributed to a new rule, fall back to the package-level failure path.
|
|
390
|
+
function replanCompileFailures(tailwind, request, initialPlan) {
|
|
391
|
+
let plan = initialPlan;
|
|
392
|
+
const blockedByStylesheet = new Map();
|
|
393
|
+
const maxIterations = plan.rules.length + 1;
|
|
394
|
+
for (let iteration = 0; ; iteration += 1) {
|
|
395
|
+
const failing = invalidCandidates(tailwind, plan.candidates);
|
|
396
|
+
if (failing.length === 0) break;
|
|
397
|
+
let progressed = false;
|
|
398
|
+
for (const rule of plan.rules) {
|
|
399
|
+
const failed = rule.candidates.filter((candidate) => failing.includes(candidate));
|
|
400
|
+
if (failed.length === 0) continue;
|
|
401
|
+
let blocked = blockedByStylesheet.get(rule.file);
|
|
402
|
+
if (!blocked) blockedByStylesheet.set(rule.file, blocked = new Map());
|
|
403
|
+
const key = `${rule.ruleId.start}-${rule.ruleId.end}`;
|
|
404
|
+
let entry = blocked.get(key);
|
|
405
|
+
if (!entry) {
|
|
406
|
+
blocked.set(key, entry = { ruleId: rule.ruleId, authoredSpan: rule.authoredSpan, candidates: new Set() });
|
|
407
|
+
progressed = true;
|
|
408
|
+
}
|
|
409
|
+
for (const candidate of failed) entry.candidates.add(candidate);
|
|
410
|
+
}
|
|
411
|
+
if (!progressed || iteration >= maxIterations) {
|
|
412
|
+
throw new Error(`Tailwind did not generate CSS for candidate: ${failing[0]}`);
|
|
413
|
+
}
|
|
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
|
+
})));
|
|
422
|
+
}
|
|
423
|
+
for (const [cssPath, blocked] of blockedByStylesheet) {
|
|
424
|
+
for (const { authoredSpan, candidates } of blocked.values()) {
|
|
425
|
+
const failed = [...candidates].sort().map((candidate) => `\`${candidate}\``).join(', ');
|
|
426
|
+
plan.warnings.push({
|
|
427
|
+
code: 'candidate-compilation-failure',
|
|
428
|
+
file: cssPath,
|
|
429
|
+
// ruleId is a compiled-domain span; warnings anchor to the authored file.
|
|
430
|
+
start: authoredSpan.start,
|
|
431
|
+
end: authoredSpan.end,
|
|
432
|
+
message: `Tailwind did not generate CSS for ${failed}, so the rule is retained.`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return plan;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function removeMigratedHtmlLinks(plan, preparedHtml) {
|
|
440
|
+
const unlinked = new Set(plan.unlinkedFiles);
|
|
441
|
+
const linksByFile = new Map();
|
|
442
|
+
for (const link of preparedHtml.removableLinks) {
|
|
443
|
+
if (!unlinked.has(link.cssPath)) continue;
|
|
444
|
+
const links = linksByFile.get(link.filePath) ?? new Set();
|
|
445
|
+
links.add(`${link.href}\0${link.media}`);
|
|
446
|
+
linksByFile.set(link.filePath, links);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const [filePath, removable] of linksByFile) {
|
|
450
|
+
const planned = plan.files.find((file) => file.path === filePath);
|
|
451
|
+
const original = preparedHtml.files.find((file) => file.path === filePath);
|
|
452
|
+
if (!original) continue;
|
|
453
|
+
const source = planned?.source ?? original.source;
|
|
454
|
+
const links = parseHtmlSource(filePath, source).links
|
|
455
|
+
.filter((link) => removable.has(`${link.href}\0${link.media}`))
|
|
456
|
+
.sort((left, right) => right.tagStart - left.tagStart);
|
|
457
|
+
let bytes = Buffer.from(source);
|
|
458
|
+
for (const link of links) {
|
|
459
|
+
bytes = Buffer.concat([bytes.subarray(0, link.tagStart), bytes.subarray(link.tagEnd)]);
|
|
460
|
+
}
|
|
461
|
+
const updated = bytes.toString();
|
|
462
|
+
if (updated === source) continue;
|
|
463
|
+
if (planned) planned.source = updated;
|
|
464
|
+
else plan.files.push({ path: filePath, source: updated });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function mergePlans(plans, originals) {
|
|
469
|
+
const filesByPath = new Map();
|
|
470
|
+
const deletedPaths = new Set();
|
|
471
|
+
const candidates = new Set();
|
|
472
|
+
const rules = [];
|
|
473
|
+
const warnings = [];
|
|
474
|
+
const seenWarnings = new Set();
|
|
475
|
+
let convertedRules = 0;
|
|
476
|
+
let retainedRules = 0;
|
|
477
|
+
|
|
478
|
+
for (const plan of plans) {
|
|
479
|
+
for (const file of plan.files) {
|
|
480
|
+
if (!originals.has(file.path)) throw new Error(`Planned file is outside the source snapshot: ${file.path}`);
|
|
481
|
+
if (filesByPath.has(file.path) || deletedPaths.has(file.path)) {
|
|
482
|
+
throw new Error(`Multiple package groups planned changes for ${file.path}`);
|
|
483
|
+
}
|
|
484
|
+
filesByPath.set(file.path, file);
|
|
485
|
+
}
|
|
486
|
+
for (const path of plan.deletedFiles) {
|
|
487
|
+
if (!originals.has(path)) throw new Error(`Planned deletion is outside the source snapshot: ${path}`);
|
|
488
|
+
if (filesByPath.has(path) || deletedPaths.has(path)) {
|
|
489
|
+
throw new Error(`Multiple package groups planned changes for ${path}`);
|
|
490
|
+
}
|
|
491
|
+
deletedPaths.add(path);
|
|
492
|
+
}
|
|
493
|
+
for (const candidate of plan.candidates) candidates.add(candidate);
|
|
494
|
+
convertedRules += plan.convertedRules;
|
|
495
|
+
retainedRules += plan.retainedRules;
|
|
496
|
+
rules.push(...plan.rules);
|
|
497
|
+
// Per-stylesheet planning repeats the same source-site warning once per
|
|
498
|
+
// stylesheet; the user-facing report keeps the first of each.
|
|
499
|
+
for (const warning of plan.warnings) {
|
|
500
|
+
const key = `${warning.code}\0${warning.file}\0${warning.start}\0${warning.end}`;
|
|
501
|
+
if (seenWarnings.has(key)) continue;
|
|
502
|
+
seenWarnings.add(key);
|
|
503
|
+
warnings.push(warning);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { filesByPath, deletedPaths, candidates, rules, warnings, convertedRules, retainedRules };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function findPackageRoot(start) {
|
|
510
|
+
let directory = start;
|
|
511
|
+
while (true) {
|
|
512
|
+
try {
|
|
513
|
+
await readFile(join(directory, 'package.json'), 'utf8');
|
|
514
|
+
return directory;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error.code !== 'ENOENT') throw error;
|
|
517
|
+
}
|
|
518
|
+
const parent = dirname(directory);
|
|
519
|
+
if (parent === directory) throw new Error(`No package.json was found from ${start}`);
|
|
520
|
+
directory = parent;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function findGitRoot(cwd) {
|
|
525
|
+
try {
|
|
526
|
+
const { stdout } = await run('git', ['rev-parse', '--show-toplevel'], { cwd });
|
|
527
|
+
return resolve(stdout.trim());
|
|
528
|
+
} catch {
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function isIgnoredByGit(gitRoot, path) {
|
|
534
|
+
if (gitRoot === path) return false;
|
|
535
|
+
try {
|
|
536
|
+
await run('git', ['check-ignore', '-q', '--', relative(gitRoot, path)], { cwd: gitRoot });
|
|
537
|
+
return true;
|
|
538
|
+
} catch {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function discoverFiles(root, useGit) {
|
|
544
|
+
if (!useGit) return collectFiles(root, isRelevantDiscoveredFile);
|
|
545
|
+
|
|
546
|
+
const { stdout } = await run(
|
|
547
|
+
'git',
|
|
548
|
+
['ls-files', '-co', '--exclude-standard', '-z', '--', '.'],
|
|
549
|
+
{ cwd: root, maxBuffer: 64 * 1024 * 1024 },
|
|
550
|
+
);
|
|
551
|
+
const paths = stdout
|
|
552
|
+
.split('\0')
|
|
553
|
+
.filter(Boolean)
|
|
554
|
+
.map((path) => resolve(root, path))
|
|
555
|
+
.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();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function discoverPackageRoots(workspaceRoot, paths) {
|
|
567
|
+
const roots = paths
|
|
568
|
+
.filter((path) => basename(path) === 'package.json')
|
|
569
|
+
.map(dirname);
|
|
570
|
+
try {
|
|
571
|
+
await readFile(join(workspaceRoot, 'package.json'), 'utf8');
|
|
572
|
+
roots.push(workspaceRoot);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error.code !== 'ENOENT') throw error;
|
|
575
|
+
}
|
|
576
|
+
return [...new Set(roots)].sort();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function owningPackage(path, packageRoots) {
|
|
580
|
+
return packageRoots
|
|
581
|
+
.filter((root) => isWithin(root, path))
|
|
582
|
+
.sort((left, right) => right.length - left.length)[0];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function isWithin(root, path) {
|
|
586
|
+
return path === root || path.startsWith(`${root}${sep}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function hasIgnoredDirectory(root, path) {
|
|
590
|
+
return relative(root, path).split(/[\\/]/).some((part) => IGNORED_DIRECTORIES.has(part));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isRelevantDiscoveredFile(path) {
|
|
594
|
+
return basename(path) === 'package.json'
|
|
595
|
+
|| isStylesheetPath(path)
|
|
596
|
+
|| SOURCE_EXTENSIONS.has(extension(path));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function isStylesheetPath(path) {
|
|
600
|
+
return STYLESHEET_SYNTAX.has(extension(path));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function stylesheetSyntax(path) {
|
|
604
|
+
return STYLESHEET_SYNTAX.get(extension(path));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function isStylesheetModule(path) {
|
|
608
|
+
const syntax = stylesheetSyntax(path);
|
|
609
|
+
return syntax !== undefined && path.endsWith(`.module.${syntax}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function sourceReferencesStyle(file, stylePath) {
|
|
613
|
+
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));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function preparePackageHtml({
|
|
620
|
+
packageRoot,
|
|
621
|
+
sourceFiles,
|
|
622
|
+
styleSources,
|
|
623
|
+
snapshots,
|
|
624
|
+
pathOwners,
|
|
625
|
+
styleDependents,
|
|
626
|
+
}) {
|
|
627
|
+
const files = [];
|
|
628
|
+
const stylePaths = new Set();
|
|
629
|
+
const generatedPaths = new Set();
|
|
630
|
+
const removableLinks = [];
|
|
631
|
+
const warnings = [];
|
|
632
|
+
|
|
633
|
+
const htmlFiles = sourceFiles.filter((file) => extension(file.path) === '.html');
|
|
634
|
+
// Package-owned HTML goes first so stylesheets it discovers are claimed for
|
|
635
|
+
// this package before foreign consumers are matched against that ownership.
|
|
636
|
+
const orderedFiles = [
|
|
637
|
+
...htmlFiles.filter((file) => pathOwners.get(file.path) === packageRoot),
|
|
638
|
+
...htmlFiles.filter((file) => pathOwners.get(file.path) !== packageRoot),
|
|
639
|
+
];
|
|
640
|
+
for (const file of orderedFiles) {
|
|
641
|
+
const owner = pathOwners.get(file.path);
|
|
642
|
+
// Foreign HTML is analyzed only as a consumer of this package's
|
|
643
|
+
// stylesheets; its other links and attributes are its own package's
|
|
644
|
+
// concern, so they never warn here.
|
|
645
|
+
const foreign = owner !== packageRoot;
|
|
646
|
+
if (foreign && !owner) continue;
|
|
647
|
+
const referenceRoot = foreign ? owner : packageRoot;
|
|
648
|
+
const parsed = parseHtmlSource(file.path, file.source);
|
|
649
|
+
const contexts = [];
|
|
650
|
+
let linkBase = dirname(file.path);
|
|
651
|
+
const base = parsed.bases[0];
|
|
652
|
+
if (base) {
|
|
653
|
+
const baseReference = base.href.split(/[?#]/, 1)[0];
|
|
654
|
+
const basePath = base.writable && (baseReference === ''
|
|
655
|
+
? file.path
|
|
656
|
+
: localHtmlReference(referenceRoot, dirname(file.path), base.href));
|
|
657
|
+
if (!basePath || !isWithin(referenceRoot, basePath)) {
|
|
658
|
+
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
|
+
));
|
|
666
|
+
}
|
|
667
|
+
linkBase = undefined;
|
|
668
|
+
} else {
|
|
669
|
+
linkBase = base.href.split(/[?#]/, 1)[0].endsWith('/') ? basePath : dirname(basePath);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
for (const link of parsed.links) {
|
|
673
|
+
const linkedPath = linkBase && localHtmlReference(referenceRoot, linkBase, link.href);
|
|
674
|
+
if (!linkedPath || !isWithin(packageRoot, linkedPath)
|
|
675
|
+
|| (foreign && pathOwners.get(linkedPath) !== packageRoot)) {
|
|
676
|
+
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
|
+
));
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const variants = mediaVariants(link.media);
|
|
688
|
+
if (variants === undefined) {
|
|
689
|
+
const cssPath = (foreign ? undefined : inferredPreprocessorPath({
|
|
690
|
+
path: linkedPath,
|
|
691
|
+
packageRoot,
|
|
692
|
+
styleSources,
|
|
693
|
+
pathOwners,
|
|
694
|
+
styleDependents,
|
|
695
|
+
})) ?? linkedPath;
|
|
696
|
+
contexts.push({ cssPath, variants: [], direct: true, analyzable: false });
|
|
697
|
+
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
|
+
));
|
|
705
|
+
}
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const contextStart = contexts.length;
|
|
709
|
+
await collectHtmlStyleContexts({
|
|
710
|
+
path: linkedPath,
|
|
711
|
+
variants,
|
|
712
|
+
direct: true,
|
|
713
|
+
packageRoot,
|
|
714
|
+
sourceFiles,
|
|
715
|
+
styleSources,
|
|
716
|
+
snapshots,
|
|
717
|
+
pathOwners,
|
|
718
|
+
styleDependents,
|
|
719
|
+
stylePaths,
|
|
720
|
+
generatedPaths,
|
|
721
|
+
contexts,
|
|
722
|
+
warnings,
|
|
723
|
+
visited: new Set(),
|
|
724
|
+
});
|
|
725
|
+
const directContext = contexts[contextStart];
|
|
726
|
+
if (directContext?.direct) {
|
|
727
|
+
removableLinks.push({
|
|
728
|
+
filePath: file.path,
|
|
729
|
+
cssPath: directContext.cssPath,
|
|
730
|
+
href: link.href,
|
|
731
|
+
media: link.media,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (foreign && contexts.length === 0) continue;
|
|
737
|
+
if (!foreign && contexts.length > 0) {
|
|
738
|
+
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
|
+
));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
files.push({
|
|
749
|
+
...file,
|
|
750
|
+
htmlElements: parsed.elements,
|
|
751
|
+
htmlStylesheets: deduplicateHtmlContexts(contexts),
|
|
752
|
+
htmlReferencesSafe: parsed.dynamicAttributes.length === 0,
|
|
753
|
+
htmlScriptText: parsed.scriptText,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return { files, stylePaths, generatedPaths, removableLinks, warnings };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function collectHtmlStyleContexts(state) {
|
|
761
|
+
const key = `${state.path}\0${state.variants.join(':')}`;
|
|
762
|
+
if (state.visited.has(key)) return;
|
|
763
|
+
state.visited.add(key);
|
|
764
|
+
if (!isStylesheetPath(state.path)) return;
|
|
765
|
+
const owner = state.pathOwners.get(state.path);
|
|
766
|
+
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
|
+
));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let source;
|
|
778
|
+
try {
|
|
779
|
+
source = state.styleSources.get(state.path)
|
|
780
|
+
?? await snapshotFile(state.snapshots, state.path);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
if (error.code === 'ENOENT') {
|
|
783
|
+
if (extension(state.path) === '.css') addInferredPreprocessorContext(state);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
if (!state.styleSources.has(state.path)) state.styleSources.set(state.path, source);
|
|
789
|
+
if (!owner) state.pathOwners.set(state.path, state.packageRoot);
|
|
790
|
+
|
|
791
|
+
if (extension(state.path) === '.css' && addInferredPreprocessorContext(state)) return;
|
|
792
|
+
|
|
793
|
+
state.stylePaths.add(state.path);
|
|
794
|
+
state.contexts.push({
|
|
795
|
+
cssPath: state.path,
|
|
796
|
+
variants: state.variants,
|
|
797
|
+
direct: state.direct,
|
|
798
|
+
analyzable: true,
|
|
799
|
+
});
|
|
800
|
+
if (extension(state.path) !== '.css') return;
|
|
801
|
+
|
|
802
|
+
for (const imported of cssImports(source)) {
|
|
803
|
+
const variants = mediaVariants(imported.media);
|
|
804
|
+
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
|
+
));
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
const importedPath = localHtmlReference(state.packageRoot, dirname(state.path), imported.href);
|
|
815
|
+
if (!importedPath || !isWithin(state.packageRoot, importedPath)) continue;
|
|
816
|
+
// Link-discovered stylesheets never went through indexStylesheetDependents,
|
|
817
|
+
// so record their import edges here or deletion could leave this importer
|
|
818
|
+
// pointing at a removed module.
|
|
819
|
+
if (importedPath !== state.path
|
|
820
|
+
&& (isStylesheetModule(importedPath) || isPreprocessorPath(importedPath))) {
|
|
821
|
+
addStyleDependent(state.styleDependents, importedPath, state.path);
|
|
822
|
+
}
|
|
823
|
+
await collectHtmlStyleContexts({
|
|
824
|
+
...state,
|
|
825
|
+
path: importedPath,
|
|
826
|
+
// Deduplicate so a cyclic import chain cannot grow the variant list and
|
|
827
|
+
// mint a fresh visited key on every lap.
|
|
828
|
+
variants: [...new Set([...state.variants, ...variants])],
|
|
829
|
+
direct: false,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
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,
|
|
842
|
+
);
|
|
843
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function addInferredPreprocessorContext(state) {
|
|
847
|
+
// A source importing the generated CSS pins the artifact itself: excluding
|
|
848
|
+
// it from planning while migrating the inferred entry could delete the only
|
|
849
|
+
// 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))) {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
const path = inferredPreprocessorPath(state);
|
|
856
|
+
if (!path) return false;
|
|
857
|
+
state.generatedPaths.add(state.path);
|
|
858
|
+
state.stylePaths.add(path);
|
|
859
|
+
state.contexts.push({
|
|
860
|
+
cssPath: path,
|
|
861
|
+
variants: state.variants,
|
|
862
|
+
direct: state.direct,
|
|
863
|
+
analyzable: true,
|
|
864
|
+
});
|
|
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
|
+
));
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function cssImports(source) {
|
|
876
|
+
const imports = [];
|
|
877
|
+
const masked = maskCssComments(source);
|
|
878
|
+
let depth = 0;
|
|
879
|
+
let quote;
|
|
880
|
+
// Browsers ignore @import once any block rule has appeared, so imports
|
|
881
|
+
// after the first top-level `{` never load and must not be traversed.
|
|
882
|
+
let importsAllowed = true;
|
|
883
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
884
|
+
const character = masked[index];
|
|
885
|
+
if (quote) {
|
|
886
|
+
if (character === '\\') index += 1;
|
|
887
|
+
else if (character === quote) quote = undefined;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (character === '"' || character === "'") {
|
|
891
|
+
quote = character;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (character === '{') {
|
|
895
|
+
depth += 1;
|
|
896
|
+
importsAllowed = false;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (character === '}') {
|
|
900
|
+
depth = Math.max(0, depth - 1);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (!importsAllowed || depth !== 0
|
|
904
|
+
|| masked.slice(index, index + 7).toLowerCase() !== '@import'
|
|
905
|
+
|| /[-\w]/.test(masked[index + 7] ?? '')) continue;
|
|
906
|
+
|
|
907
|
+
let end = index + 7;
|
|
908
|
+
let importQuote;
|
|
909
|
+
let parentheses = 0;
|
|
910
|
+
for (; end < masked.length; end += 1) {
|
|
911
|
+
const next = masked[end];
|
|
912
|
+
if (importQuote) {
|
|
913
|
+
if (next === '\\') end += 1;
|
|
914
|
+
else if (next === importQuote) importQuote = undefined;
|
|
915
|
+
} 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;
|
|
919
|
+
}
|
|
920
|
+
if (end >= masked.length) continue;
|
|
921
|
+
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
|
+
});
|
|
929
|
+
index = end;
|
|
930
|
+
}
|
|
931
|
+
return imports;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function localHtmlReference(packageRoot, base, reference) {
|
|
935
|
+
const path = reference.split(/[?#]/, 1)[0];
|
|
936
|
+
if (!path || path.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(path)) return undefined;
|
|
937
|
+
let decoded;
|
|
938
|
+
try {
|
|
939
|
+
decoded = decodeURIComponent(path);
|
|
940
|
+
} catch {
|
|
941
|
+
return undefined;
|
|
942
|
+
}
|
|
943
|
+
return decoded.startsWith('/')
|
|
944
|
+
? resolve(packageRoot, `.${decoded}`)
|
|
945
|
+
: resolve(base, decoded);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function mediaVariants(media) {
|
|
949
|
+
const normalized = media.trim().toLowerCase();
|
|
950
|
+
if (!normalized || normalized === 'all') return [];
|
|
951
|
+
if (normalized === 'print') return ['print'];
|
|
952
|
+
return undefined;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function deduplicateHtmlContexts(contexts) {
|
|
956
|
+
const unique = new Map();
|
|
957
|
+
for (const context of contexts) {
|
|
958
|
+
const key = `${context.cssPath}\0${context.variants.join(':')}\0${context.analyzable}`;
|
|
959
|
+
const existing = unique.get(key);
|
|
960
|
+
if (existing) existing.direct ||= context.direct;
|
|
961
|
+
else unique.set(key, { ...context });
|
|
962
|
+
}
|
|
963
|
+
return [...unique.values()];
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function htmlWarning(code, file, start, end, message) {
|
|
967
|
+
return { code, file, start, end, message };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function isProjectInput(workspaceRoot, path) {
|
|
971
|
+
return isWithin(workspaceRoot, path)
|
|
972
|
+
&& !relative(workspaceRoot, path).split(/[\\/]/).includes('node_modules');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function rejectSymlinkTarget(path, root) {
|
|
976
|
+
for (let current = path; isWithin(root, current); current = dirname(current)) {
|
|
977
|
+
if ((await lstat(current)).isSymbolicLink()) {
|
|
978
|
+
throw new Error(`Refusing to migrate a symbolic-link target: ${path}`);
|
|
979
|
+
}
|
|
980
|
+
if (current === root) break;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function snapshotFile(snapshots, path) {
|
|
985
|
+
return recordSnapshot(snapshots, path, await readFile(path, 'utf8'));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function recordSnapshot(snapshots, path, source) {
|
|
989
|
+
if (snapshots.has(path) && snapshots.get(path) !== source) {
|
|
990
|
+
throw new Error(`Source changed during planning: ${path}`);
|
|
991
|
+
}
|
|
992
|
+
snapshots.set(path, source);
|
|
993
|
+
return source;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function packageFailure(workspaceRoot, packageRoot, error) {
|
|
997
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
998
|
+
return {
|
|
999
|
+
package: normalizedRelativePath(workspaceRoot, packageRoot) || '.',
|
|
1000
|
+
message: message.startsWith(RECOVERABLE_INPUT_ERROR)
|
|
1001
|
+
? message.slice(RECOVERABLE_INPUT_ERROR.length)
|
|
1002
|
+
: message,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function isRecoverablePlanningError(error) {
|
|
1007
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1008
|
+
return message.startsWith(RECOVERABLE_INPUT_ERROR);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function isIntegrityError(error) {
|
|
1012
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1013
|
+
return message.startsWith('Source changed during planning:');
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function readSources(paths, snapshots) {
|
|
1017
|
+
return new Map(await Promise.all(paths.map(async (path) => [path, await snapshotFile(snapshots, path)])));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function collectFiles(root, include) {
|
|
1021
|
+
const files = [];
|
|
1022
|
+
async function visit(directory) {
|
|
1023
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
1024
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
1025
|
+
for (const entry of entries) {
|
|
1026
|
+
const path = join(directory, entry.name);
|
|
1027
|
+
if (entry.isDirectory()) {
|
|
1028
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) await visit(path);
|
|
1029
|
+
} else if (entry.isFile() && include(path)) {
|
|
1030
|
+
files.push(path);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
await visit(root);
|
|
1035
|
+
return files;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function extension(path) {
|
|
1039
|
+
const match = /\.[^.\/]+$/.exec(path);
|
|
1040
|
+
return match?.[0] ?? '';
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function normalizedRelativePath(root, path) {
|
|
1044
|
+
return relative(root, path).split(sep).join('/');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
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));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function indexStylesheetDependents(styleSources) {
|
|
1056
|
+
const dependents = new Map();
|
|
1057
|
+
for (const [path, rawSource] of styleSources) {
|
|
1058
|
+
const source = maskCssComments(rawSource);
|
|
1059
|
+
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]),
|
|
1069
|
+
];
|
|
1070
|
+
for (const reference of new Set(references)) {
|
|
1071
|
+
for (const target of stylesheetReferenceTargets(path, reference, styleSources)) {
|
|
1072
|
+
if (target === path || (!isStylesheetModule(target) && !isPreprocessorPath(target))) continue;
|
|
1073
|
+
const paths = dependents.get(target) ?? [];
|
|
1074
|
+
paths.push(path);
|
|
1075
|
+
dependents.set(target, paths);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
for (const [target, paths] of dependents) dependents.set(target, [...new Set(paths)].sort());
|
|
1080
|
+
return dependents;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function addStyleDependent(styleDependents, target, importer) {
|
|
1084
|
+
const paths = styleDependents.get(target) ?? [];
|
|
1085
|
+
if (paths.includes(importer)) return;
|
|
1086
|
+
paths.push(importer);
|
|
1087
|
+
paths.sort();
|
|
1088
|
+
styleDependents.set(target, paths);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function stylesheetReferenceTargets(importer, reference, styleSources) {
|
|
1092
|
+
const target = resolve(dirname(importer), reference);
|
|
1093
|
+
const candidates = STYLESHEET_SYNTAX.has(extension(target))
|
|
1094
|
+
? [target]
|
|
1095
|
+
: [...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
|
+
]);
|
|
1101
|
+
return candidates.filter((path) => styleSources.has(path));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function resolveTailwindEntry(stylePaths, styleSources, configuredPath) {
|
|
1105
|
+
const entries = stylePaths.filter((path) => {
|
|
1106
|
+
if (extension(path) !== '.css') return false;
|
|
1107
|
+
const source = maskCssComments(styleSources.get(path));
|
|
1108
|
+
return /@import\s+["']tailwindcss(?:\/[^"']*)?["']/.test(source);
|
|
1109
|
+
});
|
|
1110
|
+
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.');
|
|
1113
|
+
return { path: entries[0], entries };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function loadTailwind(packageRoot, tailwindCss, snapshots, workspaceRoot) {
|
|
1117
|
+
const projectRequire = createRequire(join(packageRoot, 'package.json'));
|
|
1118
|
+
let packagePath;
|
|
1119
|
+
try {
|
|
1120
|
+
packagePath = projectRequire.resolve('tailwindcss/package.json');
|
|
1121
|
+
} catch {
|
|
1122
|
+
throw new Error('Tailwind v4 must be installed in the target project.');
|
|
1123
|
+
}
|
|
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}.`);
|
|
1126
|
+
|
|
1127
|
+
const modulePath = projectRequire.resolve('tailwindcss');
|
|
1128
|
+
const tailwindModule = await import(pathToFileURL(modulePath));
|
|
1129
|
+
const { __unstable__loadDesignSystem: loadDesignSystem } = tailwindModule.default ?? tailwindModule;
|
|
1130
|
+
const css = await snapshotFile(snapshots, tailwindCss);
|
|
1131
|
+
const base = dirname(tailwindCss);
|
|
1132
|
+
const loadModule = createModuleLoader(snapshots, workspaceRoot);
|
|
1133
|
+
const loadStylesheet = createStylesheetLoader(projectRequire, packagePath, snapshots, workspaceRoot);
|
|
1134
|
+
const defaultTheme = await readFile(join(dirname(packagePath), 'theme.css'), 'utf8');
|
|
1135
|
+
const themeTokens = {
|
|
1136
|
+
...extractThemeTokens(defaultTheme),
|
|
1137
|
+
...(await extractThemeTokensFromGraph(css, base, loadStylesheet)),
|
|
1138
|
+
};
|
|
1139
|
+
const designSystem = await loadDesignSystem(css, { base, loadModule, loadStylesheet });
|
|
1140
|
+
return { designSystem, css, path: tailwindCss, themeTokens };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function extractThemeTokens(css) {
|
|
1144
|
+
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();
|
|
1147
|
+
}
|
|
1148
|
+
return tokens;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function extractThemeTokensFromGraph(css, base, loadStylesheet, seen = new Set()) {
|
|
1152
|
+
const tokens = {};
|
|
1153
|
+
for (const match of css.matchAll(/@import\s+["']([^"']+)["']/g)) {
|
|
1154
|
+
const key = `${base}\0${match[1]}`;
|
|
1155
|
+
if (seen.has(key)) continue;
|
|
1156
|
+
seen.add(key);
|
|
1157
|
+
const loaded = await loadStylesheet(match[1], base);
|
|
1158
|
+
Object.assign(tokens, await extractThemeTokensFromGraph(loaded.content, loaded.base, loadStylesheet, seen));
|
|
1159
|
+
}
|
|
1160
|
+
return Object.assign(tokens, extractThemeTokens(css));
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function invalidCandidates(tailwind, candidates) {
|
|
1164
|
+
const generated = tailwind.designSystem.candidatesToCss(candidates);
|
|
1165
|
+
return candidates.filter((_, index) => generated[index] === null);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function createModuleLoader(snapshots, workspaceRoot) {
|
|
1169
|
+
return async (id, base) => {
|
|
1170
|
+
const path = createRequire(join(base, 'package.json')).resolve(id);
|
|
1171
|
+
if (isProjectInput(workspaceRoot, path)) await snapshotFile(snapshots, path);
|
|
1172
|
+
const imported = await import(pathToFileURL(path));
|
|
1173
|
+
return { path, base: dirname(path), module: imported.default ?? imported };
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function createStylesheetLoader(projectRequire, tailwindPackagePath, snapshots, workspaceRoot) {
|
|
1178
|
+
const tailwindRoot = dirname(tailwindPackagePath);
|
|
1179
|
+
return async (id, base) => {
|
|
1180
|
+
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);
|
|
1186
|
+
else path = projectRequire.resolve(id);
|
|
1187
|
+
const content = isProjectInput(workspaceRoot, path)
|
|
1188
|
+
? await snapshotFile(snapshots, path)
|
|
1189
|
+
: await readFile(path, 'utf8');
|
|
1190
|
+
return { content, base: dirname(path) };
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function unifiedDiff(path, before, after) {
|
|
1195
|
+
const oldLines = before.split('\n');
|
|
1196
|
+
const newLines = after.split('\n');
|
|
1197
|
+
return [
|
|
1198
|
+
`--- a/${path}\n`,
|
|
1199
|
+
`+++ b/${path}\n`,
|
|
1200
|
+
`@@ -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('');
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function verifySnapshots(snapshots) {
|
|
1207
|
+
for (const [path, before] of [...snapshots].sort(([left], [right]) => left.localeCompare(right))) {
|
|
1208
|
+
let current;
|
|
1209
|
+
try {
|
|
1210
|
+
current = await readFile(path, 'utf8');
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
throw new Error(`Source changed after planning: ${path} (${error.code ?? error.message})`);
|
|
1213
|
+
}
|
|
1214
|
+
if (current !== before) throw new Error(`Source changed after planning: ${path}`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
async function writeChanges(changes, deletions) {
|
|
1219
|
+
const token = `${process.pid}-${Date.now()}`;
|
|
1220
|
+
const staged = changes.map((change, index) => [
|
|
1221
|
+
join(dirname(change.path), `.${basename(change.path)}.tw-migrate-${token}-${index}`),
|
|
1222
|
+
change,
|
|
1223
|
+
]);
|
|
1224
|
+
const backups = [...changes, ...deletions].map((change, index) => [
|
|
1225
|
+
join(dirname(change.path), `.${basename(change.path)}.tw-migrate-backup-${token}-${index}`),
|
|
1226
|
+
change.path,
|
|
1227
|
+
]);
|
|
1228
|
+
const backedUp = [];
|
|
1229
|
+
let succeeded = false;
|
|
1230
|
+
try {
|
|
1231
|
+
for (const [temporaryPath, change] of staged) {
|
|
1232
|
+
const { mode } = await stat(change.path);
|
|
1233
|
+
await writeFile(temporaryPath, change.source);
|
|
1234
|
+
await chmod(temporaryPath, mode & 0o777);
|
|
1235
|
+
}
|
|
1236
|
+
for (const [backupPath, originalPath] of backups) {
|
|
1237
|
+
await rename(originalPath, backupPath);
|
|
1238
|
+
backedUp.push([backupPath, originalPath]);
|
|
1239
|
+
}
|
|
1240
|
+
for (const [temporaryPath, change] of staged) await rename(temporaryPath, change.path);
|
|
1241
|
+
succeeded = true;
|
|
1242
|
+
} finally {
|
|
1243
|
+
if (succeeded) {
|
|
1244
|
+
await Promise.all(backups.map(([backupPath]) => rm(backupPath, { force: true })));
|
|
1245
|
+
} else {
|
|
1246
|
+
for (const [backupPath, originalPath] of backedUp.reverse()) {
|
|
1247
|
+
try {
|
|
1248
|
+
await rename(backupPath, originalPath);
|
|
1249
|
+
} catch {}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
await Promise.all(staged.map(([temporaryPath]) => rm(temporaryPath, { force: true })));
|
|
1253
|
+
}
|
|
1254
|
+
}
|