mirror of
https://github.com/aphrody-labs/tailwindcss.git
synced 2026-10-17 12:45:52 +00:00
Make upgrade tooling more stable (#19846)
This PR is an attempt to make the upgrade tooling more stable. ### TL;DR 1. When migrating from Tailwind CSS v3 → Tailwind CSS v4, only migrate files listed in the `config.content` instead of relying on v4's auto content detection feature 2. Skip writing files that have not been changed 3. Write changed files in a safe way: first write to a temporary file, then rename the file atomically 4. Never migrate files that are git ignored, even if they are listed in the `config.content` file 5. Always ignore `.env` and `.env.*` files when scanning for files. Most people will have this in their `.gitignore` file, but if not, then this is a fallback mechanism. --- Looking at the #18972 issue, it looks like some people are running into weird situations where some of the contents is just gone. I have never been able to reproduce this on my own devices and in my own projects unfortunately. But there is definitely _something_ happening that's not right that people are running into. Therefore, this PR is an attempt to fix what I think _might_ be wrong, but I'm not 100% sure if these fixes are enough, or if something else is still happening here. This builds on top of the #19779 PR which has some small fixes, but is incomplete to make this work. ### What's happening Looking at some of the comments, it looks like a few things are happening such as: 1. The upgrade tool is emptying out my files — it looks like these are only happening if you ctrl+c while the process is taking a while. It could be that a lot of files are being checked and therefore the tooling looks like its stuck. 6. The upgrade tool is looking at files it shouldn't look at — in Tailwind CSS v4 we have this concept of the auto-content detection. This means that we will look at any plain text file that is not git ignored. ### Fixes #### Emptying out files The files being emptied looks like it's because how `fs.writeFile` behaves by default. It opens the file handle with the `w` flag, which will first truncate the file before writing the new contents. This is not a single atomic operation, so a killed process in the middle will cause invalid state. When we migrate your template files, everything is happening in promises to migrate things at the same time. When a lot of files are being scanned, truncating might have happened already before we write the new content. Since we migrate a bunch of files in parallel, a ctrl+c could cause data loss in multiple files. To mitigate this, I switched to an alternative way of writing files. 1. First, we do some quick checks where if the contents didn't change we just bail out immediately. Files that don't include Tailwind CSS classes won't change, and therefore we don't need to override these files with the same contents. 2. When the migrated contents is empty, we bail out as well. I'm 100% sure that this is not the spot where the "emptying out" happens, I still believe it happens in the `writeFile` itself, but added it just in case. 7. Next, I introduced a safe write, where we first write to a temporary file in the same folder. We could write it to `/tmp`, but then we can't guarantee that we are on the same file system. If we ctrl+c at this stage, then the worst case scenario is that you have additional temporary files in your project, but your original files are still there. Once that file was written, we will use the atomic `fs.rename`. This should be atomic as long as we are on the same file system, so either the rename didn't happen yet, or it completed. I added an integration test for this, but I had to change the `writeFile` implementation slightly. In the test, we will truncate the file first, after that we will write the new contents. This is so that we have enough time to kill the current process and allows us to verify that we didn't clear out the file. Again, this is a hacky way of testing this, just because I can't reproduce this issue myself, let alone reproduce it reliable in a CI environment. Note: we are also using `realpath` to make sure that we are updating the real file. Otherwise, if we were dealing with a symlinked file, we would override the symlink with a "hard" copy instead. #### Touching files that should not be touched During the migration, we rely on the Tailwind CSS v4 auto detection logic which means that it will scan any plain text file that is not git ignored. Therefore changes to php files could happen because in theory they could contain Tailwind CSS classes. To solve this, when migrating from Tailwind CSS v3 to Tailwind CSS v4, we will _only_ take the sources into account that were listed in the `config.content` array. Since this was a requirement in Tailwind CSS v3, it should be safe to rely on this array. Additionally, this will make sure that we are dealing with way fewer files to migrate as well. On top of that, files that match the patterns in the content array that are git ignored will also be skipped. This is to prevent that we mutate files in `node_modules` for example. In one of the comments I read that `.env` files were emptied out. In most cases people will have these files gitignored but I explicitly added `.env` and `.env.*` as files to never ever touch by default when scanning. Last but not least, this also updates the output a little bit of the upgrade tool in case we skip content files (because of git ignore) and if we changed a file. <img width="1122" height="1376" alt="image" src="https://github.com/user-attachments/assets/318fdbbf-e319-4c7e-9648-ee9283842624" /> - "Git ignored folder, skipping: `./node_modules`": this is because the content array looks like this while the `node_modules` are being ignored: <img width="1090" height="398" alt="image" src="https://github.com/user-attachments/assets/7d694720-5671-47ec-bb2e-f24c5f2c4248" /> - "Migrated `./resources/views/vendor/filament-panels/components/logo.blade.php`": this is because **I** made a change to showcase this feature. Fixes: #18972 Closes: #19779 ### Test plan 1. Existing tests still pass 1. Added a dedicated integration test to ensure that we only take `config.content` into account when migrating from Tailwind CSS v3 to Tailwind CSS v4 projects. 1. Added a dedicated integration test to make sure that files listed in `config.content` that are also git ignored, will still be skipped. 1. Added a dedicated integration test to ensure that when `writeFile` is cancelled mid-write that our old files are still present. 1. Added a dedicated integration test to ensure that we ignore `.env` and `.env.*` files even if you didn't git ignore them. [ci-all] To verify on Windows --------- Co-authored-by: Sami <sychocouldy@gmail.com>
This commit is contained in:
1 parent
2c1ef9eb25
commit
e4856c9720
9 files changed
+512
-24
No files matched your search
@@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Canonicalization: collapse `overflow-{x,y}-*` into `overflow-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
|
||||
- Canonicalization: collapse `overscroll-{x,y}-*` into `overscroll-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
|
||||
- Read from `--placeholder-color` instead of `--background-color` for `placeholder-*` utilities ([#19843](https://github.com/tailwindlabs/tailwindcss/pull/19843))
|
||||
- Upgrade: Ensure files are not emptied out when killing the upgrade process while it's running ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
|
||||
- Upgrade: Use `config.content` when migrating from Tailwind CSS v3 to Tailwind CSS v4 ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
|
||||
- Upgrade: Never migrate files that are ignored by git ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
|
||||
- Add `.env` and `.env.*` to default ignored content files ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
|
||||
|
||||
## [4.2.2] - 2026-03-18
|
||||
|
||||
|
||||
@@ -2,3 +2,5 @@ package-lock.json
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path'
|
||||
import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git'
|
||||
import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
|
||||
import { candidate, css, html, js, json, test, ts, txt, yaml } from '../utils'
|
||||
|
||||
test(
|
||||
'error when no CSS file with @tailwind is used',
|
||||
@@ -171,6 +171,56 @@ test(
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'only migrates files matched by `config.content` when upgrading from v3 to v4',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "^3",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tailwind.config.js': js`
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.html'],
|
||||
}
|
||||
`,
|
||||
'src/index.html': html`
|
||||
<div class="order-[0] bg-[--my-red]"></div>
|
||||
`,
|
||||
'src/input.css': css`
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
`,
|
||||
'templates/email.php': html`
|
||||
<div class="order-[0] bg-[--my-red]"></div>
|
||||
`,
|
||||
'notes/unrelated.txt': `order-[0] bg-[--my-red]`,
|
||||
},
|
||||
},
|
||||
async ({ exec, fs, expect }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
expect(await fs.dumpFiles('./**/*.{html,php,txt}')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- notes/unrelated.txt ---
|
||||
order-[0] bg-[--my-red]
|
||||
|
||||
--- src/index.html ---
|
||||
<div class="order-0 bg-(--my-red)"></div>
|
||||
|
||||
--- templates/email.php ---
|
||||
<div class="order-[0] bg-[--my-red]"></div>
|
||||
"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
`upgrades a v3 project with prefixes to v4`,
|
||||
{
|
||||
@@ -2972,6 +3022,198 @@ test(
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'v4 ignores .env files during template migration',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/app.css': css`@import 'tailwindcss';`,
|
||||
'src/index.html': html`
|
||||
<div class="order-[0]"></div>
|
||||
`,
|
||||
'src/.env': `TW_TEST_CLASS=order-[0]`,
|
||||
'src/.env.production': `TW_TEST_CLASS=order-[0]`,
|
||||
},
|
||||
},
|
||||
async ({ exec, fs, expect }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
expect(await fs.dumpFiles('./src/**/{*,.env,.env.*}')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.html ---
|
||||
<div class="order-0"></div>
|
||||
|
||||
--- ./src/.env ---
|
||||
TW_TEST_CLASS=order-[0]
|
||||
|
||||
--- ./src/.env.production ---
|
||||
TW_TEST_CLASS=order-[0]
|
||||
|
||||
--- ./src/app.css ---
|
||||
@import 'tailwindcss';
|
||||
"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'v4 linked configs respect `content` and still ignore gitignored files',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tailwind.config.js': js`
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/*.html', './src/*.less'],
|
||||
}
|
||||
`,
|
||||
'src/app.css': css`
|
||||
@import 'tailwindcss';
|
||||
@config '../tailwind.config.js';
|
||||
`,
|
||||
|
||||
// Ignore all .html files
|
||||
'src/.gitignore': txt`
|
||||
*.html
|
||||
`,
|
||||
|
||||
// HTML files are in .gitignore, even though they are explicitly mentioned
|
||||
// in the `content` array. Still ignore them
|
||||
'src/do-not-migrate-me.html': html`
|
||||
<div class="order-[0]"></div>
|
||||
`,
|
||||
|
||||
// Should be picked up by auto-content detection
|
||||
'templates/migrate-me.php': html`
|
||||
<div class="order-[0]"></div>
|
||||
`,
|
||||
|
||||
// Does not get picked up by auto content detection (because it's a less
|
||||
// file), but was explicitly listed in the `content` array.
|
||||
//
|
||||
// A bit of a hacky way, I admit, but it allows us to differentiate
|
||||
// between git ignored files, auto content detection and explicitly listed
|
||||
// files.
|
||||
'src/migrate-me.less': html`
|
||||
<div class="order-[0]"></div>
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ exec, fs, expect }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
expect(await fs.dumpFiles('./{src,templates}/**/*')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/app.css ---
|
||||
@import 'tailwindcss';
|
||||
@config '../tailwind.config.js';
|
||||
|
||||
--- ./src/do-not-migrate-me.html ---
|
||||
<div class="order-[0]"></div>
|
||||
|
||||
--- ./src/migrate-me.less ---
|
||||
<div class="order-0"></div>
|
||||
|
||||
--- ./templates/migrate-me.php ---
|
||||
<div class="order-0"></div>
|
||||
"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'interrupting template migration does not truncate files',
|
||||
{
|
||||
timeout: 180_000,
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/app.css': css` @import 'tailwindcss'; `,
|
||||
'src/index.html': html`
|
||||
<div class="order-[0]"></div>
|
||||
`,
|
||||
'hook.cjs': js`
|
||||
let fs = require('node:fs/promises')
|
||||
let path = require('node:path')
|
||||
let originalWriteFile = fs.writeFile.bind(fs)
|
||||
|
||||
fs.writeFile = async (file, contents, ...rest) => {
|
||||
// Mimic a bad write
|
||||
await originalWriteFile(file, '') // As-if we truncated first
|
||||
console.error('__TRUNCATED_TARGET__')
|
||||
await new Promise((r) => setTimeout(r, 50)) // Wait 50ms to allow us to kill the process
|
||||
await originalWriteFile(file, contents, ...rest) // Write the actual contents
|
||||
}
|
||||
`,
|
||||
'src/keep.php': `
|
||||
<?php
|
||||
|
||||
return [
|
||||
'keep' => 'this file should never be truncated',
|
||||
];
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ spawn, fs, expect }) => {
|
||||
let repeatedCandidates = Array.from(
|
||||
{ length: 250 },
|
||||
() => '<div class="order-[0]"></div>',
|
||||
).join('\n')
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await fs.write(
|
||||
`src/templates/template-${i}.php`,
|
||||
`<?php\n\n${repeatedCandidates}\n\nreturn ['template' => ${i}];\n`,
|
||||
)
|
||||
}
|
||||
|
||||
let originalKeepFile = await fs.read('src/keep.php')
|
||||
let originalTemplate = await fs.read('src/templates/template-0.php')
|
||||
|
||||
let process = await spawn('npx @tailwindcss/upgrade --force', {
|
||||
env: {
|
||||
NODE_OPTIONS: '--require=./hook.cjs',
|
||||
},
|
||||
})
|
||||
|
||||
// We're only interested once we start migrating the templates
|
||||
await process.onStderr((message) => message.includes('Migrating templates'))
|
||||
|
||||
// Wait for the trigger that we are mid-write
|
||||
await process.onStderr((message) => message === '__TRUNCATED_TARGET__')
|
||||
|
||||
// Kill the process
|
||||
await process.dispose()
|
||||
|
||||
expect(await fs.read('src/keep.php')).toBe(originalKeepFile)
|
||||
expect(await fs.read('src/templates/template-0.php')).toBe(originalTemplate)
|
||||
|
||||
for (let [file, contents] of await fs.glob('src/**/*.{html,php,css}')) {
|
||||
expect(contents.trim(), `${file} should not be empty after interruption`).not.toBe('')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'upgrades can run in a pnpm workspace',
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path'
|
||||
import { describe } from 'vitest'
|
||||
import { css, html, json, test, ts } from '../utils'
|
||||
import { css, html, json, test, ts, txt } from '../utils'
|
||||
|
||||
test(
|
||||
`upgrade JS config files with flat theme values, darkMode, and content fields`,
|
||||
@@ -322,6 +322,105 @@ test(
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'skips gitignored template files even when they are explicitly referenced in `content`',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "^3",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'.gitignore': txt`
|
||||
node_modules
|
||||
src/ignored
|
||||
`,
|
||||
'tailwind.config.ts': ts`
|
||||
import { type Config } from 'tailwindcss'
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/migrate-me.html',
|
||||
'./src/ignored/do-not-migrate-me.html',
|
||||
'./node_modules/my-external-lib/template.html',
|
||||
],
|
||||
theme: {},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
`,
|
||||
'src/input.css': css`
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
`,
|
||||
'src/migrate-me.html': html`
|
||||
<div class="order-[0]"></div>
|
||||
`,
|
||||
'src/ignored/do-not-migrate-me.html': html`
|
||||
<div class="order-[0]"></div>
|
||||
`,
|
||||
'node_modules/my-external-lib/template.html': html`
|
||||
<div
|
||||
class="order-[0]"
|
||||
></div>
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ exec, fs, expect }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
expect(
|
||||
await fs.dumpFiles(
|
||||
'{src/**/*.{css,html},node_modules/my-external-lib/template.html,.gitignore}',
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- .gitignore ---
|
||||
node_modules
|
||||
src/ignored
|
||||
|
||||
--- src/input.css ---
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source './ignored/do-not-migrate-me.html';
|
||||
@source '../node_modules/my-external-lib/template.html';
|
||||
|
||||
/*
|
||||
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
--- src/migrate-me.html ---
|
||||
<div class="order-0"></div>
|
||||
|
||||
--- node_modules/my-external-lib/template.html ---
|
||||
<div
|
||||
class="order-[0]"
|
||||
></div>
|
||||
|
||||
--- src/ignored/do-not-migrate-me.html ---
|
||||
<div class="order-[0]"></div>
|
||||
"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'upgrades JS config files with plugins',
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((na
|
||||
)
|
||||
|
||||
interface SpawnedProcess {
|
||||
dispose: () => void
|
||||
dispose: () => Promise<void>
|
||||
flush: () => void
|
||||
onStdout: (predicate: (message: string) => boolean) => Promise<void>
|
||||
onStderr: (predicate: (message: string) => boolean) => Promise<void>
|
||||
@@ -194,6 +194,7 @@ export function test(
|
||||
return disposePromise
|
||||
}
|
||||
disposables.push(dispose)
|
||||
|
||||
function onExit() {
|
||||
resolveDisposal?.()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pkg } from '../../utils/packages'
|
||||
import { highlight, info, relative, success, warn } from '../../utils/renderer'
|
||||
import { writeFileSafely } from '../../utils/write-file-safely'
|
||||
|
||||
// Migrates simple PostCSS setups. This is to cover non-dynamic config files
|
||||
// similar to the ones we have all over our docs:
|
||||
@@ -50,7 +51,7 @@ export async function migratePostCSSConfig(base: string) {
|
||||
ranMigration = true
|
||||
|
||||
if (result) {
|
||||
await fs.writeFile(
|
||||
await writeFileSafely(
|
||||
packageJsonPath,
|
||||
JSON.stringify({ ...packageJson, postcss: result?.json }, null, 2),
|
||||
)
|
||||
@@ -76,7 +77,7 @@ export async function migratePostCSSConfig(base: string) {
|
||||
ranMigration = true
|
||||
|
||||
if (result) {
|
||||
await fs.writeFile(jsonConfigPath, JSON.stringify(result.json, null, 2))
|
||||
await writeFileSafely(jsonConfigPath, JSON.stringify(result.json, null, 2))
|
||||
|
||||
didMigrate = true
|
||||
didAddPostcssClient = result.didAddPostcssClient
|
||||
@@ -206,7 +207,7 @@ async function migratePostCSSJSConfig(configPath: string): Promise<{
|
||||
newLines.push(line)
|
||||
}
|
||||
}
|
||||
await fs.writeFile(configPath, newLines.join('\n'))
|
||||
await writeFileSafely(configPath, newLines.join('\n'))
|
||||
|
||||
return { didAddPostcssClient, didRemoveAutoprefixer, didRemovePostCSSImport }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
||||
import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string'
|
||||
import { writeFileSafely } from '../../utils/write-file-safely'
|
||||
import { extractRawCandidates } from './candidates'
|
||||
import { isSafeMigration } from './is-safe-migration'
|
||||
import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection'
|
||||
@@ -119,12 +120,18 @@ export default async function migrateContents(
|
||||
return spliceChangesIntoString(contents, changes)
|
||||
}
|
||||
|
||||
export async function migrate(designSystem: DesignSystem, userConfig: Config | null, file: string) {
|
||||
export async function migrate(
|
||||
designSystem: DesignSystem,
|
||||
userConfig: Config | null,
|
||||
file: string,
|
||||
): Promise<boolean> {
|
||||
let fullPath = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file)
|
||||
let contents = await fs.readFile(fullPath, 'utf-8')
|
||||
|
||||
await fs.writeFile(
|
||||
fullPath,
|
||||
await migrateContents(designSystem, userConfig, contents, extname(file)),
|
||||
)
|
||||
let migrated = await migrateContents(designSystem, userConfig, contents, extname(file))
|
||||
if (migrated === contents) return false // Nothing changed
|
||||
if (migrated.trim() === '') return false // Emptied out, something went horribly wrong
|
||||
|
||||
await writeFileSafely(fullPath, migrated)
|
||||
return true
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Scanner } from '@tailwindcss/oxide'
|
||||
import { globby } from 'globby'
|
||||
import { globby, isGitIgnored } from 'globby'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import pc from 'picocolors'
|
||||
@@ -23,6 +23,7 @@ import { isRepoDirty } from './utils/git'
|
||||
import { pkg } from './utils/packages'
|
||||
import { eprintln, error, header, highlight, info, relative, success } from './utils/renderer'
|
||||
import * as version from './utils/version'
|
||||
import { writeFileSafely } from './utils/write-file-safely'
|
||||
|
||||
const options = {
|
||||
'--config': { type: 'string', description: 'Path to the configuration file', alias: '-c' },
|
||||
@@ -42,6 +43,7 @@ if (flags['--help']) {
|
||||
|
||||
async function run() {
|
||||
let base = process.cwd()
|
||||
let isIgnored = await isGitIgnored({ cwd: base })
|
||||
|
||||
eprintln(header())
|
||||
eprintln()
|
||||
@@ -95,6 +97,7 @@ async function run() {
|
||||
info('Searching for CSS files in the current directory and its subdirectories…')
|
||||
|
||||
files = await globby(['**/*.css'], {
|
||||
cwd: base,
|
||||
absolute: true,
|
||||
gitignore: true,
|
||||
// gitignore: true will first search for all .gitignore including node_modules folders, this makes the initial search much faster
|
||||
@@ -250,7 +253,7 @@ async function run() {
|
||||
for (let sheet of stylesheets) {
|
||||
if (!sheet.file) continue
|
||||
|
||||
await fs.writeFile(sheet.file, sheet.root.toString())
|
||||
await writeFileSafely(sheet.file, sheet.root.toString())
|
||||
|
||||
if (sheet.isTailwindRoot) {
|
||||
success(`Migrated stylesheet: ${highlight(relative(sheet.file, base))}`, { prefix: '↳ ' })
|
||||
@@ -293,6 +296,8 @@ async function run() {
|
||||
let designSystem = await sheet.designSystem()
|
||||
if (!designSystem) continue
|
||||
|
||||
let config = configBySheet.get(sheet)
|
||||
|
||||
// Figure out the source files to migrate
|
||||
let sources = (() => {
|
||||
// Disable auto source detection
|
||||
@@ -300,43 +305,121 @@ async function run() {
|
||||
return []
|
||||
}
|
||||
|
||||
// No root specified, use the base directory
|
||||
// No root specified
|
||||
if (compiler.root === null) {
|
||||
// When coming from Tailwind CSS v3, we have to use the
|
||||
// `config.sources` (which came from `config.content` originally)
|
||||
if (version.isMajor(3)) {
|
||||
if (config?.sources) {
|
||||
return config.sources.map((source) => ({ ...source, negated: false }))
|
||||
}
|
||||
|
||||
// When we don't have any sources, then we have to fallback to no
|
||||
// sources at all. We cannot fallback to the `**/*` pattern.
|
||||
return []
|
||||
}
|
||||
|
||||
// When we are upgrading a Tailwind CSS v4 and up version, we use
|
||||
// the default `**/*` pattern. All custom `@source` directives will
|
||||
// be attached later as sources.
|
||||
return [{ base, pattern: '**/*', negated: false }]
|
||||
}
|
||||
|
||||
// Use the specified root
|
||||
return [{ ...compiler.root, negated: false }]
|
||||
})().concat(compiler.sources)
|
||||
|
||||
let config = configBySheet.get(sheet)
|
||||
let scanner = new Scanner({ sources })
|
||||
let filesToMigrate = []
|
||||
|
||||
let ignoredPaths = new Set<string>()
|
||||
|
||||
for (let file of scanner.files) {
|
||||
file = await fs.realpath(file).catch(() => file) // Ensure we are dealing with the real path, not symlinks
|
||||
if (file.endsWith('.css')) continue
|
||||
|
||||
// When a file is git ignored, then we don't want to migrate it even
|
||||
// if it was listed in the `config.content` array or part of any
|
||||
// `@source` directives.
|
||||
//
|
||||
// We can make this an option later to explicitly allow this, but
|
||||
// this should be the default. This guarantees that:
|
||||
//
|
||||
// 1. Files coming from node_modules aren't touched
|
||||
// 2. Generated files aren't changed (the source should update, not the target)
|
||||
// 3. You can see all the changes that happened
|
||||
try {
|
||||
if (isIgnored(file)) {
|
||||
let culprit = file
|
||||
|
||||
// To prevent print all ignored files, we can also walk up the
|
||||
// parent tree and log those instead _if_ they are:
|
||||
//
|
||||
// 1. Also git ignored
|
||||
// 2. Are not going outside of the current repo
|
||||
let parent = path.dirname(file)
|
||||
do {
|
||||
try {
|
||||
if (isIgnored(parent)) {
|
||||
culprit = parent
|
||||
}
|
||||
} catch {
|
||||
// Escaping the current repo
|
||||
break
|
||||
}
|
||||
|
||||
parent = path.dirname(parent)
|
||||
} while (parent)
|
||||
|
||||
if (ignoredPaths.has(culprit)) continue // Already logged, skip
|
||||
ignoredPaths.add(culprit)
|
||||
|
||||
if (culprit === file) {
|
||||
info(`Git ignored, skipping: ${highlight(relative(culprit, base))}`, {
|
||||
prefix: '↳ ',
|
||||
})
|
||||
} else {
|
||||
info(`Git ignored folder, skipping: ${highlight(relative(culprit, base))}`, {
|
||||
prefix: '↳ ',
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
} catch (err) {
|
||||
info(`Outside repository, skipping: ${highlight(relative(file, base))}`, {
|
||||
prefix: '↳ ',
|
||||
})
|
||||
// Skip this file when we run into errors. E.g.: when the current
|
||||
// file is not part of the current git repo it will throw an error.
|
||||
continue
|
||||
}
|
||||
|
||||
if (seenFiles.has(file)) continue
|
||||
seenFiles.add(file)
|
||||
filesToMigrate.push(file)
|
||||
}
|
||||
|
||||
// Migrate each file
|
||||
let changes = 0
|
||||
await Promise.allSettled(
|
||||
filesToMigrate.map((file) =>
|
||||
migrateTemplate(designSystem, config?.userConfig ?? null, file),
|
||||
),
|
||||
filesToMigrate.map(async (file) => {
|
||||
let changed = await migrateTemplate(designSystem, config?.userConfig ?? null, file)
|
||||
if (changed) {
|
||||
changes++
|
||||
info(`Migrated ${highlight(relative(file, base))}`, { prefix: '↳ ' })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (config?.configFilePath) {
|
||||
success(
|
||||
`Migrated templates for configuration file: ${highlight(relative(config.configFilePath, base))}`,
|
||||
`Migrated templates for configuration file: ${highlight(relative(config.configFilePath, base))} (${changes} file${changes === 1 ? '' : 's'} changed)`,
|
||||
{ prefix: '↳ ' },
|
||||
)
|
||||
} else {
|
||||
success(
|
||||
`Migrated templates for: ${highlight(relative(sheet.file ?? '<unknown>', base))}`,
|
||||
{
|
||||
prefix: '↳ ',
|
||||
},
|
||||
`Migrated templates for: ${highlight(relative(sheet.file ?? '<unknown>', base))} (${changes} file${changes === 1 ? '' : 's'} changed)`,
|
||||
{ prefix: '↳ ' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export async function writeFileSafely(file: string, contents: string) {
|
||||
// Resolve symlinks so the rename replaces the actual target file rather than
|
||||
// replacing the symlink itself with a regular file.
|
||||
let realFile = await fs.realpath(file).catch(() => file)
|
||||
|
||||
// Start by creating a new file in the current directory that is guaranteed to
|
||||
// be unique (via `uuid`). We can embed the `process.id` in case we need to
|
||||
// debug things later.
|
||||
//
|
||||
// While we can write this to a more global `/tmp` folder, I want to be 100%
|
||||
// sure that we are on the same file system (same drive) so the rename
|
||||
// operation is atomic. Once the file is written, we will rename the file. If
|
||||
// this fails, the old file is still intact, if it works we have an updated
|
||||
// file.
|
||||
//
|
||||
// If this still causes problems (but it will slow things down):
|
||||
// 1. We could make sure that we inherit the file permissions
|
||||
// 2. Use an explicit fsync to force a flush to disk
|
||||
let temporaryFile = path.join(
|
||||
path.dirname(realFile),
|
||||
`.${path.basename(realFile)}.tailwind-upgrade.${process.pid}.${randomUUID()}.tmp`,
|
||||
)
|
||||
|
||||
// Write file uses the `w` flag by default, which is defined as:
|
||||
// > Open file for writing. The file is created (if it does not exist) or truncated (if it exists).
|
||||
// > https://nodejs.org/api/fs.html#file-system-flags
|
||||
//
|
||||
// Which means that if this function is actively running, and you cancel the
|
||||
// process at the wrong time, then the truncated files are present. Since all
|
||||
// these migrations happen in parallel, multiple files are open and available
|
||||
// to be written to, it could mean in multiple truncated files.
|
||||
//
|
||||
// Writing to a temp file first means that if the process is cancelled at this
|
||||
// point, that the old original file is still correct.
|
||||
//
|
||||
// The rename part should be atomic (especially because we guarantee it to be
|
||||
// on the same file system) so this either succeeds or doesn't happen.
|
||||
try {
|
||||
await fs.writeFile(temporaryFile, contents, 'utf8')
|
||||
await fs.rename(temporaryFile, realFile)
|
||||
} catch (error) {
|
||||
await fs.unlink(temporaryFile).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user