Skip to content

Commit 75eeed8

Browse files
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 <[email protected]>
1 parent 462308d commit 75eeed8

File tree

7 files changed

+239
-4
lines changed

7 files changed

+239
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- Ensure adjacent rules are merged together after handling nesting when generating optimized CSS ([#14873](https://github.com/tailwindlabs/tailwindcss/pull/14873))
3030
- _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830))
3131
- _Upgrade (experimental)_: Remove whitespace around `,` separator when print arbitrary values ([#14838](https://github.com/tailwindlabs/tailwindcss/pull/14838))
32+
- _Upgrade (experimental)_: Fix crash during upgrade when content globs escape root of project ([#14896](https://github.com/tailwindlabs/tailwindcss/pull/14896))
3233

3334
### Changed
3435

integrations/upgrade/js-config.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from 'node:path'
12
import { describe, expect } from 'vitest'
23
import { css, html, json, test, ts } from '../utils'
34

@@ -938,6 +939,98 @@ test(
938939
},
939940
)
940941

942+
test(
943+
'migrate sources when pointing to folders outside the project root',
944+
{
945+
fs: {
946+
'package.json': json`
947+
{
948+
"dependencies": {
949+
"@tailwindcss/upgrade": "workspace:^"
950+
}
951+
}
952+
`,
953+
954+
'frontend/tailwind.config.ts': ts`
955+
export default {
956+
content: {
957+
relative: true,
958+
files: ['./src/**/*.html', '../backend/mails/**/*.blade.php'],
959+
},
960+
theme: {
961+
extend: {
962+
colors: {
963+
primary: 'red',
964+
},
965+
},
966+
},
967+
}
968+
`,
969+
'frontend/src/input.css': css`
970+
@tailwind base;
971+
@tailwind components;
972+
@tailwind utilities;
973+
@config "../tailwind.config.ts";
974+
`,
975+
'frontend/src/index.html': html`<div class="!text-primary"></div>`,
976+
977+
'backend/mails/welcome.blade.php': html`<div class="!text-primary"></div>`,
978+
},
979+
},
980+
async ({ root, exec, fs }) => {
981+
await exec('npx @tailwindcss/upgrade', {
982+
cwd: path.join(root, 'frontend'),
983+
})
984+
985+
expect(await fs.dumpFiles('frontend/**/*.css')).toMatchInlineSnapshot(`
986+
"
987+
--- frontend/src/input.css ---
988+
@import 'tailwindcss';
989+
990+
@source '../../backend/mails/**/*.blade.php';
991+
992+
@theme {
993+
--color-primary: red;
994+
}
995+
996+
/*
997+
The default border color has changed to \`currentColor\` in Tailwind CSS v4,
998+
so we've added these compatibility styles to make sure everything still
999+
looks the same as it did with Tailwind CSS v3.
1000+
1001+
If we ever want to remove these styles, we need to add an explicit border
1002+
color utility to any element that depends on these defaults.
1003+
*/
1004+
@layer base {
1005+
*,
1006+
::after,
1007+
::before,
1008+
::backdrop,
1009+
::file-selector-button {
1010+
border-color: var(--color-gray-200, currentColor);
1011+
}
1012+
}
1013+
1014+
/*
1015+
Form elements have a 1px border by default in Tailwind CSS v4, so we've
1016+
added these compatibility styles to make sure everything still looks the
1017+
same as it did with Tailwind CSS v3.
1018+
1019+
If we ever want to remove these styles, we need to add \`border-0\` to
1020+
any form elements that shouldn't have a border.
1021+
*/
1022+
@layer base {
1023+
input:where(:not([type='button'], [type='reset'], [type='submit'])),
1024+
select,
1025+
textarea {
1026+
border-width: 0;
1027+
}
1028+
}
1029+
"
1030+
`)
1031+
},
1032+
)
1033+
9411034
describe('border compatibility', () => {
9421035
test(
9431036
'migrate border compatibility',

packages/@tailwindcss-upgrade/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@tailwindcss/node": "workspace:^",
3131
"@tailwindcss/oxide": "workspace:^",
32+
"braces": "^3.0.3",
3233
"dedent": "1.5.3",
3334
"enhanced-resolve": "^5.17.1",
3435
"globby": "^14.0.2",
@@ -44,6 +45,7 @@
4445
"tree-sitter-typescript": "^0.23.0"
4546
},
4647
"devDependencies": {
48+
"@types/braces": "^3.0.4",
4749
"@types/node": "catalog:",
4850
"@types/postcss-import": "^14.0.3"
4951
}

packages/@tailwindcss-upgrade/src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { migrate as migrateTemplate } from './template/migrate'
2121
import { prepareConfig } from './template/prepare-config'
2222
import { args, type Arg } from './utils/args'
2323
import { isRepoDirty } from './utils/git'
24+
import { hoistStaticGlobParts } from './utils/hoist-static-glob-parts'
2425
import { pkg } from './utils/packages'
2526
import { eprintln, error, header, highlight, info, success } from './utils/renderer'
2627

@@ -143,11 +144,11 @@ async function run() {
143144
info('Migrating templates using the provided configuration file.')
144145
for (let config of configBySheet.values()) {
145146
let set = new Set<string>()
146-
for (let { pattern, base } of config.globs) {
147-
let files = await globby([pattern], {
147+
for (let globEntry of config.globs.flatMap((entry) => hoistStaticGlobParts(entry))) {
148+
let files = await globby([globEntry.pattern], {
148149
absolute: true,
149150
gitignore: true,
150-
cwd: base,
151+
cwd: globEntry.base,
151152
})
152153

153154
for (let file of files) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, it } from 'vitest'
2+
import { hoistStaticGlobParts } from './hoist-static-glob-parts'
3+
4+
it.each([
5+
// A basic glob
6+
[
7+
{ base: '/projects/project-a', pattern: './src/**/*.html' },
8+
[{ base: '/projects/project-a/src', pattern: '**/*.html' }],
9+
],
10+
11+
// A glob pointing to a folder should result in `**/*`
12+
[
13+
{ base: '/projects/project-a', pattern: './src' },
14+
[{ base: '/projects/project-a/src', pattern: '**/*' }],
15+
],
16+
17+
// A glob pointing to a file, should result in the file as the pattern
18+
[
19+
{ base: '/projects/project-a', pattern: './src/index.html' },
20+
[{ base: '/projects/project-a/src', pattern: 'index.html' }],
21+
],
22+
23+
// A glob going up a directory, should result in the new directory as the base
24+
[
25+
{ base: '/projects/project-a', pattern: '../project-b/src/**/*.html' },
26+
[{ base: '/projects/project-b/src', pattern: '**/*.html' }],
27+
],
28+
29+
// A glob with curlies, should be expanded to multiple globs
30+
[
31+
{ base: '/projects/project-a', pattern: '../project-{b,c}/src/**/*.html' },
32+
[
33+
{ base: '/projects/project-b/src', pattern: '**/*.html' },
34+
{ base: '/projects/project-c/src', pattern: '**/*.html' },
35+
],
36+
],
37+
[
38+
{ base: '/projects/project-a', pattern: '../project-{b,c}/src/**/*.{js,html}' },
39+
[
40+
{ base: '/projects/project-b/src', pattern: '**/*.js' },
41+
{ base: '/projects/project-b/src', pattern: '**/*.html' },
42+
{ base: '/projects/project-c/src', pattern: '**/*.js' },
43+
{ base: '/projects/project-c/src', pattern: '**/*.html' },
44+
],
45+
],
46+
])('should hoist the static parts of the glob: %s', (input, output) => {
47+
expect(hoistStaticGlobParts(input)).toEqual(output)
48+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import braces from 'braces'
2+
import path from 'node:path'
3+
4+
interface GlobEntry {
5+
base: string
6+
pattern: string
7+
}
8+
9+
export function hoistStaticGlobParts(entry: GlobEntry): GlobEntry[] {
10+
return braces(entry.pattern, { expand: true }).map((pattern) => {
11+
let clone = { ...entry }
12+
let [staticPart, dynamicPart] = splitPattern(pattern)
13+
14+
// Move static part into the `base`.
15+
if (staticPart !== null) {
16+
clone.base = path.resolve(entry.base, staticPart)
17+
} else {
18+
clone.base = path.resolve(entry.base)
19+
}
20+
21+
// Move dynamic part into the `pattern`.
22+
if (dynamicPart === null) {
23+
clone.pattern = '**/*'
24+
} else {
25+
clone.pattern = dynamicPart
26+
}
27+
28+
// If the pattern looks like a file, move the file name from the `base` to
29+
// the `pattern`.
30+
let file = path.basename(clone.base)
31+
if (file.includes('.')) {
32+
clone.pattern = file
33+
clone.base = path.dirname(clone.base)
34+
}
35+
36+
return clone
37+
})
38+
}
39+
40+
// Split a glob pattern into a `static` and `dynamic` part.
41+
//
42+
// Assumption: we assume that all globs are expanded, which means that the only
43+
// dynamic parts are using `*`.
44+
//
45+
// E.g.:
46+
// Original input: `../project-b/**/*.{html,js}`
47+
// Expanded input: `../project-b/**/*.html` & `../project-b/**/*.js`
48+
// Split on first input: ("../project-b", "**/*.html")
49+
// Split on second input: ("../project-b", "**/*.js")
50+
function splitPattern(pattern: string): [staticPart: string | null, dynamicPart: string | null] {
51+
// No dynamic parts, so we can just return the input as-is.
52+
if (!pattern.includes('*')) {
53+
return [pattern, null]
54+
}
55+
56+
let lastSlashPosition: number | null = null
57+
58+
for (let i = 0; i < pattern.length; i++) {
59+
let c = pattern[i];
60+
if (c === '/') {
61+
lastSlashPosition = i
62+
}
63+
64+
if (c === '*' || c === '!') {
65+
break
66+
}
67+
}
68+
69+
// Very first character is a `*`, therefore there is no static part, only a
70+
// dynamic part.
71+
if (lastSlashPosition === null) {
72+
return [null, pattern]
73+
}
74+
75+
let staticPart = pattern.slice(0, lastSlashPosition).trim()
76+
let dynamicPart = pattern.slice(lastSlashPosition + 1).trim()
77+
78+
return [staticPart || null, dynamicPart || null]
79+
}

pnpm-lock.yaml

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)