mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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>
This commit is contained in:
parent
462308d8d7
commit
75eeed85b6
@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Ensure adjacent rules are merged together after handling nesting when generating optimized CSS ([#14873](https://github.com/tailwindlabs/tailwindcss/pull/14873))
|
||||
- _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830))
|
||||
- _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838))
|
||||
- _Upgrade (experimental)_: Fix crash during upgrade when content globs escape root of project ([#14896](https://github.com/tailwindlabs/tailwindcss/pull/14896))
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect } from 'vitest'
|
||||
import { css, html, json, test, ts } from '../utils'
|
||||
|
||||
@ -938,6 +939,98 @@ test(
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'migrate sources when pointing to folders outside the project root',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
'frontend/tailwind.config.ts': ts`
|
||||
export default {
|
||||
content: {
|
||||
relative: true,
|
||||
files: ['./src/**/*.html', '../backend/mails/**/*.blade.php'],
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'red',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`,
|
||||
'frontend/src/input.css': css`
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@config "../tailwind.config.ts";
|
||||
`,
|
||||
'frontend/src/index.html': html`<div class="!text-primary"></div>`,
|
||||
|
||||
'backend/mails/welcome.blade.php': html`<div class="!text-primary"></div>`,
|
||||
},
|
||||
},
|
||||
async ({ root, exec, fs }) => {
|
||||
await exec('npx @tailwindcss/upgrade', {
|
||||
cwd: path.join(root, 'frontend'),
|
||||
})
|
||||
|
||||
expect(await fs.dumpFiles('frontend/**/*.css')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- frontend/src/input.css ---
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../backend/mails/**/*.blade.php';
|
||||
|
||||
@theme {
|
||||
--color-primary: red;
|
||||
}
|
||||
|
||||
/*
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Form elements have a 1px border by default 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 \`border-0\` to
|
||||
any form elements that shouldn't have a border.
|
||||
*/
|
||||
@layer base {
|
||||
input:where(:not([type='button'], [type='reset'], [type='submit'])),
|
||||
select,
|
||||
textarea {
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
describe('border compatibility', () => {
|
||||
test(
|
||||
'migrate border compatibility',
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "workspace:^",
|
||||
"@tailwindcss/oxide": "workspace:^",
|
||||
"braces": "^3.0.3",
|
||||
"dedent": "1.5.3",
|
||||
"enhanced-resolve": "^5.17.1",
|
||||
"globby": "^14.0.2",
|
||||
@ -44,6 +45,7 @@
|
||||
"tree-sitter-typescript": "^0.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/braces": "^3.0.4",
|
||||
"@types/node": "catalog:",
|
||||
"@types/postcss-import": "^14.0.3"
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ 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'
|
||||
|
||||
@ -143,11 +144,11 @@ async function run() {
|
||||
info('Migrating templates using the provided configuration file.')
|
||||
for (let config of configBySheet.values()) {
|
||||
let set = new Set<string>()
|
||||
for (let { pattern, base } of config.globs) {
|
||||
let files = await globby([pattern], {
|
||||
for (let globEntry of config.globs.flatMap((entry) => hoistStaticGlobParts(entry))) {
|
||||
let files = await globby([globEntry.pattern], {
|
||||
absolute: true,
|
||||
gitignore: true,
|
||||
cwd: base,
|
||||
cwd: globEntry.base,
|
||||
})
|
||||
|
||||
for (let file of files) {
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { hoistStaticGlobParts } from './hoist-static-glob-parts'
|
||||
|
||||
it.each([
|
||||
// A basic glob
|
||||
[
|
||||
{ base: '/projects/project-a', pattern: './src/**/*.html' },
|
||||
[{ base: '/projects/project-a/src', pattern: '**/*.html' }],
|
||||
],
|
||||
|
||||
// A glob pointing to a folder should result in `**/*`
|
||||
[
|
||||
{ base: '/projects/project-a', pattern: './src' },
|
||||
[{ base: '/projects/project-a/src', pattern: '**/*' }],
|
||||
],
|
||||
|
||||
// A glob pointing to a file, should result in the file as the pattern
|
||||
[
|
||||
{ base: '/projects/project-a', pattern: './src/index.html' },
|
||||
[{ base: '/projects/project-a/src', pattern: 'index.html' }],
|
||||
],
|
||||
|
||||
// A glob going up a directory, should result in the new directory as the base
|
||||
[
|
||||
{ base: '/projects/project-a', pattern: '../project-b/src/**/*.html' },
|
||||
[{ base: '/projects/project-b/src', pattern: '**/*.html' }],
|
||||
],
|
||||
|
||||
// A glob with curlies, should be expanded to multiple globs
|
||||
[
|
||||
{ base: '/projects/project-a', pattern: '../project-{b,c}/src/**/*.html' },
|
||||
[
|
||||
{ base: '/projects/project-b/src', pattern: '**/*.html' },
|
||||
{ base: '/projects/project-c/src', pattern: '**/*.html' },
|
||||
],
|
||||
],
|
||||
[
|
||||
{ base: '/projects/project-a', pattern: '../project-{b,c}/src/**/*.{js,html}' },
|
||||
[
|
||||
{ base: '/projects/project-b/src', pattern: '**/*.js' },
|
||||
{ base: '/projects/project-b/src', pattern: '**/*.html' },
|
||||
{ base: '/projects/project-c/src', pattern: '**/*.js' },
|
||||
{ base: '/projects/project-c/src', pattern: '**/*.html' },
|
||||
],
|
||||
],
|
||||
])('should hoist the static parts of the glob: %s', (input, output) => {
|
||||
expect(hoistStaticGlobParts(input)).toEqual(output)
|
||||
})
|
||||
@ -0,0 +1,79 @@
|
||||
import braces from 'braces'
|
||||
import path from 'node:path'
|
||||
|
||||
interface GlobEntry {
|
||||
base: string
|
||||
pattern: string
|
||||
}
|
||||
|
||||
export function hoistStaticGlobParts(entry: GlobEntry): GlobEntry[] {
|
||||
return braces(entry.pattern, { expand: true }).map((pattern) => {
|
||||
let clone = { ...entry }
|
||||
let [staticPart, dynamicPart] = splitPattern(pattern)
|
||||
|
||||
// Move static part into the `base`.
|
||||
if (staticPart !== null) {
|
||||
clone.base = path.resolve(entry.base, staticPart)
|
||||
} else {
|
||||
clone.base = path.resolve(entry.base)
|
||||
}
|
||||
|
||||
// Move dynamic part into the `pattern`.
|
||||
if (dynamicPart === null) {
|
||||
clone.pattern = '**/*'
|
||||
} else {
|
||||
clone.pattern = dynamicPart
|
||||
}
|
||||
|
||||
// If the pattern looks like a file, move the file name from the `base` to
|
||||
// the `pattern`.
|
||||
let file = path.basename(clone.base)
|
||||
if (file.includes('.')) {
|
||||
clone.pattern = file
|
||||
clone.base = path.dirname(clone.base)
|
||||
}
|
||||
|
||||
return clone
|
||||
})
|
||||
}
|
||||
|
||||
// Split a glob pattern into a `static` and `dynamic` part.
|
||||
//
|
||||
// Assumption: we assume that all globs are expanded, which means that the only
|
||||
// dynamic parts are using `*`.
|
||||
//
|
||||
// E.g.:
|
||||
// Original input: `../project-b/**/*.{html,js}`
|
||||
// Expanded input: `../project-b/**/*.html` & `../project-b/**/*.js`
|
||||
// Split on first input: ("../project-b", "**/*.html")
|
||||
// Split on second input: ("../project-b", "**/*.js")
|
||||
function splitPattern(pattern: string): [staticPart: string | null, dynamicPart: string | null] {
|
||||
// No dynamic parts, so we can just return the input as-is.
|
||||
if (!pattern.includes('*')) {
|
||||
return [pattern, null]
|
||||
}
|
||||
|
||||
let lastSlashPosition: number | null = null
|
||||
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
let c = pattern[i];
|
||||
if (c === '/') {
|
||||
lastSlashPosition = i
|
||||
}
|
||||
|
||||
if (c === '*' || c === '!') {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Very first character is a `*`, therefore there is no static part, only a
|
||||
// dynamic part.
|
||||
if (lastSlashPosition === null) {
|
||||
return [null, pattern]
|
||||
}
|
||||
|
||||
let staticPart = pattern.slice(0, lastSlashPosition).trim()
|
||||
let dynamicPart = pattern.slice(lastSlashPosition + 1).trim()
|
||||
|
||||
return [staticPart || null, dynamicPart || null]
|
||||
}
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -10,7 +10,7 @@ catalogs:
|
||||
specifier: ^20.14.8
|
||||
version: 20.14.13
|
||||
lightningcss:
|
||||
specifier: ^1.26.0
|
||||
specifier: ^1.28.1
|
||||
version: 1.26.0
|
||||
vite:
|
||||
specifier: ^5.4.0
|
||||
@ -284,6 +284,9 @@ importers:
|
||||
'@tailwindcss/oxide':
|
||||
specifier: workspace:^
|
||||
version: link:../../crates/node
|
||||
braces:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
dedent:
|
||||
specifier: 1.5.3
|
||||
version: 1.5.3
|
||||
@ -324,6 +327,9 @@ importers:
|
||||
specifier: ^0.23.0
|
||||
version: 0.23.0(tree-sitter@0.22.0)
|
||||
devDependencies:
|
||||
'@types/braces':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 20.14.13
|
||||
@ -1304,6 +1310,9 @@ packages:
|
||||
'@types/babel__traverse@7.20.6':
|
||||
resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
|
||||
|
||||
'@types/braces@3.0.4':
|
||||
resolution: {integrity: sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==}
|
||||
|
||||
'@types/bun@1.1.11':
|
||||
resolution: {integrity: sha512-0N7D/H/8sbf9JMkaG5F3+I/cB4TlhKTkO9EskEWP8XDr8aVcDe4EywSnU4cnyZy6tar1dq70NeFNkqMEUigthw==}
|
||||
|
||||
@ -4120,6 +4129,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.25.2
|
||||
|
||||
'@types/braces@3.0.4': {}
|
||||
|
||||
'@types/bun@1.1.11':
|
||||
dependencies:
|
||||
bun-types: 1.1.30
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user