Robin Malfait 75eeed85b6
Fix crash during upgrade when content globs escape root of project (#14896)
This PR fixes an issue where globs in you `content` configuration escape
the current "root" of the project.

This can happen if you have a folder, and you need to look up in the
tree (e.g.: when looking at another package in a monorepo, or in case of
a Laravel project where you want to look at mail templates).

This applies a similar strategy we already implement on the Rust side.

1. Expand braces in the globs
2. Move static parts of the `pattern` to the `base` of the glob entry
object

---

Given a project setup like this:
```
.
├── admin
│   ├── my-tailwind.config.ts
│   └── src
│       ├── abc.jpg
│       ├── index.html
│       ├── index.js
│       └── styles
│           └── input.css
├── dashboard
│   ├── src
│   │   ├── index.html
│   │   ├── index.js
│   │   ├── input.css
│   │   └── pickaday.css
│   └── tailwind.config.ts
├── package-lock.json
├── package.json
├── postcss.config.js
└── unrelated
    └── index.html

7 directories, 14 files
```


If you then have this config:
```ts
// admin/my-tailwind.config.ts
export default {
  content: {
    relative: true,
    files: ['./src/**/*.html', '../dashboard/src/**/*.html'],
                            //  ^^  this is the important part, which escapes
                            //      the current root of the project.
  },
  theme: {
    extend: {
      colors: {
        primary: 'red',
      },
    },
  },
}
```


Then before this change, running the command looks like this:
<img width="1760" alt="image"
src="https://github.com/user-attachments/assets/60e2dfc7-3751-4432-80e3-8b4b8f1083d4">


After this change, running the command looks like this:
<img width="1452" alt="image"
src="https://github.com/user-attachments/assets/5c47182c-119c-4732-a253-2dace7086049">

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
2024-11-07 12:22:25 +00:00

273 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
import { globby } from 'globby'
import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import { formatNodes } from './codemods/format-nodes'
import { sortBuckets } from './codemods/sort-buckets'
import { help } from './commands/help'
import {
analyze as analyzeStylesheets,
linkConfigs as linkConfigsToStylesheets,
migrate as migrateStylesheet,
split as splitStylesheets,
} from './migrate'
import { migrateJsConfig } from './migrate-js-config'
import { migratePostCSSConfig } from './migrate-postcss'
import { migratePrettierPlugin } from './migrate-prettier'
import { Stylesheet } from './stylesheet'
import { migrate as migrateTemplate } from './template/migrate'
import { prepareConfig } from './template/prepare-config'
import { args, type Arg } from './utils/args'
import { isRepoDirty } from './utils/git'
import { hoistStaticGlobParts } from './utils/hoist-static-glob-parts'
import { pkg } from './utils/packages'
import { eprintln, error, header, highlight, info, success } from './utils/renderer'
const options = {
'--config': { type: 'string', description: 'Path to the configuration file', alias: '-c' },
'--help': { type: 'boolean', description: 'Display usage information', alias: '-h' },
'--force': { type: 'boolean', description: 'Force the migration', alias: '-f' },
'--version': { type: 'boolean', description: 'Display the version number', alias: '-v' },
} satisfies Arg
const flags = args(options)
if (flags['--help']) {
help({
usage: ['npx @tailwindcss/upgrade'],
options,
})
process.exit(0)
}
async function run() {
let base = process.cwd()
eprintln(header())
eprintln()
let cleanup: (() => void)[] = []
if (!flags['--force']) {
if (isRepoDirty()) {
error('Git directory is not clean. Please stash or commit your changes before migrating.')
info(
`You may use the ${highlight('--force')} flag to silence this warning and perform the migration.`,
)
process.exit(1)
}
}
{
// Stylesheet migrations
// Use provided files
let files = flags._.map((file) => path.resolve(base, file))
// Discover CSS files in case no files were provided
if (files.length === 0) {
info(
'No input stylesheets provided. Searching for CSS files in the current directory and its subdirectories…',
)
files = await globby(['**/*.css'], {
absolute: true,
gitignore: true,
})
}
// Ensure we are only dealing with CSS files
files = files.filter((file) => file.endsWith('.css'))
// Analyze the stylesheets
let loadResults = await Promise.allSettled(files.map((filepath) => Stylesheet.load(filepath)))
// Load and parse all stylesheets
for (let result of loadResults) {
if (result.status === 'rejected') {
error(`${result.reason}`)
}
}
let stylesheets = loadResults
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value)
// Analyze the stylesheets
try {
await analyzeStylesheets(stylesheets)
} catch (e: unknown) {
error(`${e}`)
}
// Ensure stylesheets are linked to configs
try {
await linkConfigsToStylesheets(stylesheets, {
configPath: flags['--config'],
base,
})
} catch (e: unknown) {
error(`${e}`)
}
// Migrate js config files, linked to stylesheets
info('Migrating JavaScript configuration files using the provided configuration file.')
let configBySheet = new Map<Stylesheet, Awaited<ReturnType<typeof prepareConfig>>>()
let jsConfigMigrationBySheet = new Map<
Stylesheet,
Awaited<ReturnType<typeof migrateJsConfig>>
>()
for (let sheet of stylesheets) {
if (!sheet.isTailwindRoot) continue
let config = await prepareConfig(sheet.linkedConfigPath, { base })
configBySheet.set(sheet, config)
let jsConfigMigration = await migrateJsConfig(
config.designSystem,
config.configFilePath,
base,
)
jsConfigMigrationBySheet.set(sheet, jsConfigMigration)
if (jsConfigMigration !== null) {
// Remove the JS config if it was fully migrated
cleanup.push(() => fs.rm(config.configFilePath))
}
}
// Migrate source files, linked to config files
{
// Template migrations
info('Migrating templates using the provided configuration file.')
for (let config of configBySheet.values()) {
let set = new Set<string>()
for (let globEntry of config.globs.flatMap((entry) => hoistStaticGlobParts(entry))) {
let files = await globby([globEntry.pattern], {
absolute: true,
gitignore: true,
cwd: globEntry.base,
})
for (let file of files) {
set.add(file)
}
}
let files = Array.from(set)
files.sort()
// Migrate each file
await Promise.allSettled(
files.map((file) => migrateTemplate(config.designSystem, config.userConfig, file)),
)
}
success('Template migration complete.')
}
// Migrate each CSS file
let migrateResults = await Promise.allSettled(
stylesheets.map((sheet) => {
let config = configBySheet.get(sheet)!
let jsConfigMigration = jsConfigMigrationBySheet.get(sheet)!
if (!config) {
for (let parent of sheet.ancestors()) {
if (parent.isTailwindRoot) {
config ??= configBySheet.get(parent)!
jsConfigMigration ??= jsConfigMigrationBySheet.get(parent)!
break
}
}
}
return migrateStylesheet(sheet, { ...config, jsConfigMigration })
}),
)
for (let result of migrateResults) {
if (result.status === 'rejected') {
error(`${result.reason}`)
}
}
// Split up stylesheets (as needed)
try {
await splitStylesheets(stylesheets)
} catch (e: unknown) {
error(`${e}`)
}
// Cleanup `@import "…" layer(utilities)`
for (let sheet of stylesheets) {
for (let importRule of sheet.importRules) {
if (!importRule.raws.tailwind_injected_layer) continue
let importedSheet = stylesheets.find(
(sheet) => sheet.id === importRule.raws.tailwind_destination_sheet_id,
)
if (!importedSheet) continue
// Only remove the `layer(…)` next to the import if any of the children
// contain `@utility`. Otherwise `@utility` will not be top-level.
if (
!importedSheet.containsRule((node) => node.type === 'atrule' && node.name === 'utility')
) {
continue
}
// Make sure to remove the `layer(…)` from the `@import` at-rule
importRule.params = importRule.params.replace(/ layer\([^)]+\)/, '').trim()
}
}
// Format nodes
for (let sheet of stylesheets) {
await postcss([sortBuckets(), formatNodes()]).process(sheet.root!, { from: sheet.file! })
}
// Write all files to disk
for (let sheet of stylesheets) {
if (!sheet.file) continue
await fs.writeFile(sheet.file, sheet.root.toString())
}
success('Stylesheet migration complete.')
}
{
// PostCSS config migration
await migratePostCSSConfig(base)
}
{
// Migrate the prettier plugin to the latest version
await migratePrettierPlugin(base)
}
// Run all cleanup functions because we completed the migration
await Promise.allSettled(cleanup.map((fn) => fn()))
try {
// Upgrade Tailwind CSS
await pkg(base).add(['tailwindcss@next'])
} catch {}
// Figure out if we made any changes
if (isRepoDirty()) {
success('Verify the changes and commit them to your repository.')
} else {
success('No changes were made to your repository.')
}
}
run()
.then(() => process.exit(0))
.catch((err) => {
console.error(err)
process.exit(1)
})