Allow resolving content paths relative to the config file (#9396)
* Update lockfile * Tweak formatting * Refactor content path parsing * Allow resolving content paths relative to the config file * Include resolved symlinks as additional content paths * Update changelog * Work on suite of tests for content resolution * reformat integration test list * Move content resolution tests to integration * Update future and experimental types
This commit is contained in:
parent
7ea02c2d34
commit
bf44941049
30 changed files with 2121 additions and 128 deletions
11
.github/workflows/integration-tests.yml
vendored
11
.github/workflows/integration-tests.yml
vendored
|
|
@ -15,7 +15,16 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
integration: [parcel, postcss-cli, rollup, rollup-sass, tailwindcss-cli, vite, webpack-4, webpack-5]
|
||||
integration:
|
||||
- content-resolution
|
||||
- parcel
|
||||
- postcss-cli
|
||||
- rollup
|
||||
- rollup-sass
|
||||
- tailwindcss-cli
|
||||
- vite
|
||||
- webpack-4
|
||||
- webpack-5
|
||||
node-version: [16]
|
||||
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Add negative value support for `outline-offset` ([#9136](https://github.com/tailwindlabs/tailwindcss/pull/9136))
|
||||
- Allow negating utilities using min/max/clamp ([#9237](https://github.com/tailwindlabs/tailwindcss/pull/9237))
|
||||
- Add new `collapse` utility for `visibility: collapse` ([#9181](https://github.com/tailwindlabs/tailwindcss/pull/9181))
|
||||
- Allow resolving content paths relative to the config file ([#9396](https://github.com/tailwindlabs/tailwindcss/pull/9396))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
1
integrations/content-resolution/.gitignore
vendored
Normal file
1
integrations/content-resolution/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.tailwind.config.js
|
||||
1369
integrations/content-resolution/package-lock.json
generated
Normal file
1369
integrations/content-resolution/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
integrations/content-resolution/package.json
Normal file
20
integrations/content-resolution/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "postcss-cli",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production postcss ./src/index.css -o ./dist/main.css --verbose",
|
||||
"test": "jest --runInBand --forceExit"
|
||||
},
|
||||
"jest": {
|
||||
"testTimeout": 10000,
|
||||
"displayName": "Content Resolution",
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/../../jest/customMatchers.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-cli": "^9.1.0"
|
||||
}
|
||||
}
|
||||
5
integrations/content-resolution/postcss.config.js
Normal file
5
integrations/content-resolution/postcss.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
let path = require('path')
|
||||
|
||||
module.exports = {
|
||||
plugins: [require(path.resolve(__dirname, '..', '..'))],
|
||||
}
|
||||
1
integrations/content-resolution/src/index.css
Normal file
1
integrations/content-resolution/src/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@tailwind utilities;
|
||||
1
integrations/content-resolution/src/real/no-1.js
Normal file
1
integrations/content-resolution/src/real/no-1.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// content-[real-static-negative]
|
||||
1
integrations/content-resolution/src/real/no.js
Normal file
1
integrations/content-resolution/src/real/no.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// content-[real-dynamic-negative]
|
||||
1
integrations/content-resolution/src/real/yes.html
Normal file
1
integrations/content-resolution/src/real/yes.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!-- content-[real-static-positive] -->
|
||||
1
integrations/content-resolution/src/real/yes.js
Normal file
1
integrations/content-resolution/src/real/yes.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// content-[real-dynamic-positive]
|
||||
1
integrations/content-resolution/src/resolved/no-1.js
Normal file
1
integrations/content-resolution/src/resolved/no-1.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// content-[resolved-static-negative]
|
||||
1
integrations/content-resolution/src/resolved/no.js
Normal file
1
integrations/content-resolution/src/resolved/no.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// content-[resolved-dynamic-negative]
|
||||
1
integrations/content-resolution/src/resolved/yes.html
Normal file
1
integrations/content-resolution/src/resolved/yes.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!-- content-[resolved-static-positive] -->
|
||||
1
integrations/content-resolution/src/resolved/yes.js
Normal file
1
integrations/content-resolution/src/resolved/yes.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// content-[resolved-dynamic-positive]
|
||||
53
integrations/content-resolution/tests/config.js
Normal file
53
integrations/content-resolution/tests/config.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// @ts-config
|
||||
|
||||
let fs = require('fs')
|
||||
let path = require('path')
|
||||
|
||||
module.exports.writeConfigs = async function writeConfigs({
|
||||
both = {},
|
||||
inRoot = {},
|
||||
inDir = {},
|
||||
} = {}) {
|
||||
let configs = [
|
||||
{
|
||||
path: '../tailwind.config.js',
|
||||
config: {
|
||||
...both,
|
||||
...inRoot,
|
||||
content: {
|
||||
files: [],
|
||||
...both.content,
|
||||
...inRoot.content,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '../src/tailwind.config.js',
|
||||
config: {
|
||||
...both,
|
||||
...inDir,
|
||||
content: {
|
||||
files: [],
|
||||
...both.content,
|
||||
...inDir.content,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
let defaultConfig = {
|
||||
corePlugins: { preflight: false },
|
||||
}
|
||||
|
||||
for (const config of configs) {
|
||||
await fs.promises.writeFile(
|
||||
path.resolve(__dirname, config.path),
|
||||
`module.exports = ${JSON.stringify({ ...defaultConfig, ...config.config })};`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.destroyConfigs = async function destroyConfigs() {
|
||||
await fs.promises.unlink(path.resolve(__dirname, '../tailwind.config.js'))
|
||||
await fs.promises.unlink(path.resolve(__dirname, '../src/tailwind.config.js'))
|
||||
}
|
||||
287
integrations/content-resolution/tests/content.test.js
Normal file
287
integrations/content-resolution/tests/content.test.js
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
let fs = require('fs')
|
||||
let path = require('path')
|
||||
let { cwd } = require('./cwd.js')
|
||||
let { writeConfigs, destroyConfigs } = require('./config.js')
|
||||
|
||||
let $ = require('../../execute')
|
||||
let { css } = require('../../syntax')
|
||||
|
||||
let { readOutputFile } = require('../../io')({
|
||||
output: 'dist',
|
||||
input: '.',
|
||||
})
|
||||
|
||||
// Write default configs before running tests and remove them afterwards
|
||||
beforeAll(() => writeConfigs())
|
||||
afterAll(() => destroyConfigs())
|
||||
|
||||
// Create a symlink at ./src/link that points to ./src/resolved and remove it afterwards
|
||||
beforeAll(() =>
|
||||
fs.promises.symlink(
|
||||
path.resolve(__dirname, '../src/resolved'),
|
||||
path.resolve(__dirname, '../src/link')
|
||||
)
|
||||
)
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await fs.promises.unlink(path.resolve(__dirname, '../src/link'))
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// If we've changed directories reset the cwd back to what it was before running these tests
|
||||
afterEach(() => cwd.unwind())
|
||||
|
||||
async function build({ cwd: cwdPath } = {}) {
|
||||
let inputPath = path.resolve(__dirname, '../src/index.css')
|
||||
let outputPath = path.resolve(__dirname, '../dist/main.css')
|
||||
|
||||
await cwd.switch(cwdPath)
|
||||
|
||||
// Note that ./tailwind.config.js is hardcoded on purpose here
|
||||
// It represents a config but one that could be in different places
|
||||
await $(`postcss ${inputPath} -o ${outputPath}`, {
|
||||
env: { NODE_ENV: 'production' },
|
||||
cwd: cwdPath,
|
||||
})
|
||||
|
||||
return {
|
||||
css: await readOutputFile('main.css'),
|
||||
}
|
||||
}
|
||||
|
||||
it('looks in the CWD by default', async () => {
|
||||
await writeConfigs({
|
||||
both: {
|
||||
content: {
|
||||
files: ['./src/real/yes.html'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let result = await build({ cwd: path.resolve(__dirname, '..') })
|
||||
|
||||
expect(result.css).toMatchCss(css`
|
||||
.content-\[real-static-positive\] {
|
||||
--tw-content: real-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
`)
|
||||
|
||||
result = await build({ cwd: path.resolve(__dirname, '../src') })
|
||||
|
||||
expect(result.css).toMatchCss(``)
|
||||
})
|
||||
|
||||
it('looks in the CWD for non-config-relative paths', async () => {
|
||||
await writeConfigs({
|
||||
both: {
|
||||
// Turn it on by default (eventual v4 behavior)
|
||||
experimental: { relativeContentPathsByDefault: true },
|
||||
|
||||
// But then disable it anyway
|
||||
content: {
|
||||
relative: false,
|
||||
files: ['./src/real/yes.html'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let result = await build({ cwd: path.resolve(__dirname, '..') })
|
||||
|
||||
expect(result.css).toMatchCss(css`
|
||||
.content-\[real-static-positive\] {
|
||||
--tw-content: real-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
`)
|
||||
|
||||
result = await build({ cwd: path.resolve(__dirname, '../src') })
|
||||
|
||||
expect(result.css).toMatchCss(``)
|
||||
})
|
||||
|
||||
it('can look for content files relative to the config', async () => {
|
||||
await writeConfigs({
|
||||
both: {
|
||||
content: {
|
||||
relative: true,
|
||||
files: ['./real/yes.html'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Here `./real` doesn't exist next to the config in the root directory
|
||||
let result = await build({ cwd: path.resolve(__dirname, '..') })
|
||||
|
||||
expect(result.css).toMatchCss(css``)
|
||||
|
||||
// But here it `./real` does exist next to the config in the `./src` directory!
|
||||
result = await build({ cwd: path.resolve(__dirname, '../src') })
|
||||
|
||||
expect(result.css).toMatchCss(css`
|
||||
.content-\[real-static-positive\] {
|
||||
--tw-content: real-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('it handles ignored globs correctly when not relative to the config', async () => {
|
||||
await writeConfigs({
|
||||
both: {
|
||||
content: {
|
||||
relative: false,
|
||||
files: [
|
||||
'./src/real/yes.html', // Scanned + static
|
||||
'./src/real/*.js', // Scanned + dynamic
|
||||
'!./src/real/no.js', // Ignored + static
|
||||
'!./src/real/no-*.js', // Ignored + dynamic
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let result = await build({ cwd: path.resolve(__dirname, '..') })
|
||||
|
||||
expect(result.css).toMatchCss(css`
|
||||
.content-\[real-static-positive\] {
|
||||
--tw-content: real-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[real-dynamic-positive\] {
|
||||
--tw-content: real-dynamic-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
`)
|
||||
|
||||
// But here it `./real` does exist next to the config in the `./src` directory!
|
||||
result = await build({ cwd: path.resolve(__dirname, '../src') })
|
||||
|
||||
expect(result.css).toMatchCss(``)
|
||||
})
|
||||
|
||||
it('it handles ignored globs correctly when relative to the config', async () => {
|
||||
await writeConfigs({
|
||||
both: {
|
||||
content: {
|
||||
relative: true,
|
||||
files: [
|
||||
'./real/yes.html', // Scanned + static
|
||||
'./real/*.js', // Scanned + dynamic
|
||||
'!./real/no.js', // Ignored + static
|
||||
'!./real/no-*.js', // Ignored + dynamic
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let result = await build({ cwd: path.resolve(__dirname, '..') })
|
||||
|
||||
expect(result.css).toMatchCss(``)
|
||||
|
||||
// But here it `./real` does exist next to the config in the `./src` directory!
|
||||
result = await build({ cwd: path.resolve(__dirname, '../src') })
|
||||
|
||||
expect(result.css).toMatchCss(css`
|
||||
.content-\[real-static-positive\] {
|
||||
--tw-content: real-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[real-dynamic-positive\] {
|
||||
--tw-content: real-dynamic-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('it can resolve symlinks for files when not relative to the config', async () => {
|
||||
await writeConfigs({
|
||||
both: {
|
||||
content: {
|
||||
relative: false,
|
||||
files: [
|
||||
'./src/real/yes.html', // Scanned + static
|
||||
'./src/real/*.js', // Scanned + dynamic
|
||||
'./src/link/yes.html', // Scanned + static + symlinked
|
||||
'./src/link/*.js', // Scanned + dynamic + symlinked
|
||||
'!./src/real/no.js', // Ignored + static
|
||||
'!./src/real/no-*.js', // Ignored + dynamic
|
||||
'!./src/link/no.js', // Ignored + static + symlinked
|
||||
'!./src/link/no-*.js', // Ignored + dynamic + symlinked
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let result = await build({ cwd: path.resolve(__dirname, '..') })
|
||||
|
||||
expect(result.css).toMatchCss(css`
|
||||
.content-\[real-static-positive\] {
|
||||
--tw-content: real-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[resolved-static-positive\] {
|
||||
--tw-content: resolved-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[real-dynamic-positive\] {
|
||||
--tw-content: real-dynamic-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[resolved-dynamic-positive\] {
|
||||
--tw-content: resolved-dynamic-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
`)
|
||||
|
||||
// But here it `./real` does exist next to the config in the `./src` directory!
|
||||
result = await build({ cwd: path.resolve(__dirname, '../src') })
|
||||
|
||||
expect(result.css).toMatchCss(``)
|
||||
})
|
||||
|
||||
it('it can resolve symlinks for files when relative to the config', async () => {
|
||||
await writeConfigs({
|
||||
both: {
|
||||
content: {
|
||||
relative: true,
|
||||
files: [
|
||||
'./real/yes.html', // Scanned + static
|
||||
'./real/*.js', // Scanned + dynamic
|
||||
'./link/yes.html', // Scanned + static + symlinked
|
||||
'./link/*.js', // Scanned + dynamic + symlinked
|
||||
'!./real/no.js', // Ignored + static
|
||||
'!./real/no-*.js', // Ignored + dynamic
|
||||
'!./link/no.js', // Ignored + static + symlinked
|
||||
'!./link/no-*.js', // Ignored + dynamic + symlinked
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let result = await build({ cwd: path.resolve(__dirname, '..') })
|
||||
|
||||
expect(result.css).toMatchCss(``)
|
||||
|
||||
// But here it `./real` does exist next to the config in the `./src` directory!
|
||||
result = await build({ cwd: path.resolve(__dirname, '../src') })
|
||||
|
||||
expect(result.css).toMatchCss(css`
|
||||
.content-\[real-static-positive\] {
|
||||
--tw-content: real-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[resolved-static-positive\] {
|
||||
--tw-content: resolved-static-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[real-dynamic-positive\] {
|
||||
--tw-content: real-dynamic-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
.content-\[resolved-dynamic-positive\] {
|
||||
--tw-content: resolved-dynamic-positive;
|
||||
content: var(--tw-content);
|
||||
}
|
||||
`)
|
||||
})
|
||||
24
integrations/content-resolution/tests/cwd.js
Normal file
24
integrations/content-resolution/tests/cwd.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// @ts-config
|
||||
|
||||
let stack = []
|
||||
|
||||
module.exports.cwd = {
|
||||
get current() {
|
||||
return process.cwd()
|
||||
},
|
||||
|
||||
async switch(dir) {
|
||||
stack.push(process.cwd())
|
||||
process.chdir(dir)
|
||||
},
|
||||
|
||||
async restore() {
|
||||
process.chdir(stack.pop())
|
||||
},
|
||||
|
||||
async unwind() {
|
||||
while (stack.length) {
|
||||
this.restore()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -19,14 +19,15 @@ function debounce(fn, ms) {
|
|||
|
||||
module.exports = function $(command, options = {}) {
|
||||
let abortController = new AbortController()
|
||||
let cwd = resolveToolRoot()
|
||||
let root = resolveToolRoot()
|
||||
let cwd = options.cwd ?? root
|
||||
|
||||
let args = options.shell
|
||||
? [command]
|
||||
: (() => {
|
||||
let args = command.split(' ')
|
||||
command = args.shift()
|
||||
command = command === 'node' ? command : path.resolve(cwd, 'node_modules', '.bin', command)
|
||||
command = command === 'node' ? command : path.resolve(root, 'node_modules', '.bin', command)
|
||||
return [command, args]
|
||||
})()
|
||||
|
||||
|
|
|
|||
46
integrations/tailwindcss-cli/package-lock.json
generated
46
integrations/tailwindcss-cli/package-lock.json
generated
|
|
@ -12,13 +12,13 @@
|
|||
}
|
||||
},
|
||||
"../..": {
|
||||
"version": "3.0.24",
|
||||
"version": "3.1.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"arg": "^5.0.1",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.5.3",
|
||||
"color-name": "^1.1.4",
|
||||
"detective": "^5.2.0",
|
||||
"detective": "^5.2.1",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.2.11",
|
||||
|
|
@ -36,26 +36,26 @@
|
|||
"postcss-selector-parser": "^6.0.10",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"quick-lru": "^5.1.1",
|
||||
"resolve": "^1.22.0"
|
||||
"resolve": "^1.22.1"
|
||||
},
|
||||
"bin": {
|
||||
"tailwind": "lib/cli.js",
|
||||
"tailwindcss": "lib/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/css": "^1.11.2",
|
||||
"@swc/cli": "^0.1.57",
|
||||
"@swc/core": "^1.2.160",
|
||||
"@swc/core": "^1.2.196",
|
||||
"@swc/jest": "^0.2.21",
|
||||
"@swc/register": "^0.1.10",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"cssnano": "^5.1.9",
|
||||
"esbuild": "^0.14.39",
|
||||
"eslint": "^8.15.0",
|
||||
"esbuild": "^0.14.48",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^28.0.3",
|
||||
"jest-diff": "^28.1.0",
|
||||
"prettier": "^2.6.2",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"jest": "^28.1.2",
|
||||
"jest-diff": "^28.1.1",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.11",
|
||||
"rimraf": "^3.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
|
|
@ -76,27 +76,27 @@
|
|||
"tailwindcss": {
|
||||
"version": "file:../..",
|
||||
"requires": {
|
||||
"@parcel/css": "^1.11.2",
|
||||
"@swc/cli": "^0.1.57",
|
||||
"@swc/core": "^1.2.160",
|
||||
"@swc/core": "^1.2.196",
|
||||
"@swc/jest": "^0.2.21",
|
||||
"@swc/register": "^0.1.10",
|
||||
"arg": "^5.0.1",
|
||||
"arg": "^5.0.2",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"chokidar": "^3.5.3",
|
||||
"color-name": "^1.1.4",
|
||||
"cssnano": "^5.1.9",
|
||||
"detective": "^5.2.0",
|
||||
"detective": "^5.2.1",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"esbuild": "^0.14.39",
|
||||
"eslint": "^8.15.0",
|
||||
"esbuild": "^0.14.48",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"fast-glob": "^3.2.11",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jest": "^28.0.3",
|
||||
"jest-diff": "^28.1.0",
|
||||
"jest": "^28.1.2",
|
||||
"jest-diff": "^28.1.1",
|
||||
"lilconfig": "^2.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
"object-hash": "^3.0.0",
|
||||
|
|
@ -108,10 +108,10 @@
|
|||
"postcss-nested": "5.0.6",
|
||||
"postcss-selector-parser": "^6.0.10",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"prettier": "^2.6.2",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.11",
|
||||
"quick-lru": "^5.1.1",
|
||||
"resolve": "^1.22.0",
|
||||
"resolve": "^1.22.1",
|
||||
"rimraf": "^3.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
|
|
|
|||
17
src/cli.js
17
src/cli.js
|
|
@ -19,6 +19,7 @@ import packageJson from '../package.json'
|
|||
import normalizePath from 'normalize-path'
|
||||
import micromatch from 'micromatch'
|
||||
import { validateConfig } from './util/validateConfig.js'
|
||||
import { parseCandidateFiles } from './lib/content.js'
|
||||
|
||||
let env = {
|
||||
DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0',
|
||||
|
|
@ -551,14 +552,14 @@ async function build() {
|
|||
}
|
||||
|
||||
function extractFileGlobs(config) {
|
||||
return config.content.files
|
||||
.filter((file) => {
|
||||
// Strings in this case are files / globs. If it is something else,
|
||||
// like an object it's probably a raw content object. But this object
|
||||
// is not watchable, so let's remove it.
|
||||
return typeof file === 'string'
|
||||
})
|
||||
.map((glob) => normalizePath(glob))
|
||||
let context = {
|
||||
tailwindConfig: config,
|
||||
userConfigPath: configPath,
|
||||
}
|
||||
|
||||
let contentPaths = parseCandidateFiles(context, config)
|
||||
|
||||
return contentPaths.map((contentPath) => contentPath.pattern)
|
||||
}
|
||||
|
||||
function extractRawContent(config) {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,13 @@ let featureFlags = {
|
|||
'hoverOnlyWhenSupported',
|
||||
'respectDefaultRingColorOpacity',
|
||||
'disableColorOpacityUtilitiesByDefault',
|
||||
'relativeContentPathsByDefault',
|
||||
],
|
||||
experimental: [
|
||||
'optimizeUniversalDefaults',
|
||||
'matchVariant',
|
||||
// 'variantGrouping',
|
||||
],
|
||||
experimental: ['optimizeUniversalDefaults', 'matchVariant' /* , 'variantGrouping' */],
|
||||
}
|
||||
|
||||
export function flagEnabled(config, flag) {
|
||||
|
|
|
|||
199
src/lib/content.js
Normal file
199
src/lib/content.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// @ts-check
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import isGlob from 'is-glob'
|
||||
import fastGlob from 'fast-glob'
|
||||
import normalizePath from 'normalize-path'
|
||||
import { parseGlob } from '../util/parseGlob'
|
||||
import { env } from './sharedState'
|
||||
|
||||
/** @typedef {import('../../types/config.js').RawFile} RawFile */
|
||||
/** @typedef {import('../../types/config.js').FilePath} FilePath */
|
||||
|
||||
/**
|
||||
* @typedef {object} ContentPath
|
||||
* @property {string} original
|
||||
* @property {string} base
|
||||
* @property {string | null} glob
|
||||
* @property {boolean} ignore
|
||||
* @property {string} pattern
|
||||
*/
|
||||
|
||||
/**
|
||||
* Turn a list of content paths (absolute or not; glob or not) into a list of
|
||||
* absolute file paths that exist on the filesystem
|
||||
*
|
||||
* If there are symlinks in the path then multiple paths will be returned
|
||||
* one for the symlink and one for the actual file
|
||||
*
|
||||
* @param {*} context
|
||||
* @param {import('tailwindcss').Config} tailwindConfig
|
||||
* @returns {ContentPath[]}
|
||||
*/
|
||||
export function parseCandidateFiles(context, tailwindConfig) {
|
||||
let files = tailwindConfig.content.files
|
||||
|
||||
// Normalize the file globs
|
||||
files = files.filter((filePath) => typeof filePath === 'string')
|
||||
files = files.map(normalizePath)
|
||||
|
||||
// Split into included and excluded globs
|
||||
let tasks = fastGlob.generateTasks(files)
|
||||
|
||||
/** @type {ContentPath[]} */
|
||||
let included = []
|
||||
|
||||
/** @type {ContentPath[]} */
|
||||
let excluded = []
|
||||
|
||||
for (const task of tasks) {
|
||||
included.push(...task.positive.map((filePath) => parseFilePath(filePath, false)))
|
||||
excluded.push(...task.negative.map((filePath) => parseFilePath(filePath, true)))
|
||||
}
|
||||
|
||||
let paths = [...included, ...excluded]
|
||||
|
||||
// Resolve paths relative to the config file or cwd
|
||||
paths = resolveRelativePaths(context, paths)
|
||||
|
||||
// Resolve symlinks if possible
|
||||
paths = paths.flatMap(resolvePathSymlinks)
|
||||
|
||||
// Update cached patterns
|
||||
paths = paths.map(resolveGlobPattern)
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {boolean} ignore
|
||||
* @returns {ContentPath}
|
||||
*/
|
||||
function parseFilePath(filePath, ignore) {
|
||||
let contentPath = {
|
||||
original: filePath,
|
||||
base: filePath,
|
||||
ignore,
|
||||
pattern: filePath,
|
||||
glob: null,
|
||||
}
|
||||
|
||||
if (isGlob(filePath)) {
|
||||
Object.assign(contentPath, parseGlob(filePath))
|
||||
}
|
||||
|
||||
return contentPath
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ContentPath} contentPath
|
||||
* @returns {ContentPath}
|
||||
*/
|
||||
function resolveGlobPattern(contentPath) {
|
||||
contentPath.pattern = contentPath.glob
|
||||
? `${contentPath.base}/${contentPath.glob}`
|
||||
: contentPath.base
|
||||
|
||||
contentPath.pattern = contentPath.ignore ? `!${contentPath.pattern}` : contentPath.pattern
|
||||
|
||||
return contentPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each path relative to the config file (when possible) if the experimental flag is enabled
|
||||
* Otherwise, resolve relative to the current working directory
|
||||
*
|
||||
* @param {any} context
|
||||
* @param {ContentPath[]} contentPaths
|
||||
* @returns {ContentPath[]}
|
||||
*/
|
||||
function resolveRelativePaths(context, contentPaths) {
|
||||
let resolveFrom = []
|
||||
|
||||
// Resolve base paths relative to the config file (when possible) if the experimental flag is enabled
|
||||
if (context.userConfigPath && context.tailwindConfig.content.relative) {
|
||||
resolveFrom = [path.dirname(context.userConfigPath)]
|
||||
}
|
||||
|
||||
return contentPaths.map((contentPath) => {
|
||||
contentPath.base = path.resolve(...resolveFrom, contentPath.base)
|
||||
|
||||
return contentPath
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the symlink for the base directory / file in each path
|
||||
* These are added as additional dependencies to watch for changes because
|
||||
* some tools (like webpack) will only watch the actual file or directory
|
||||
* but not the symlink itself even in projects that use monorepos.
|
||||
*
|
||||
* @param {ContentPath} contentPath
|
||||
* @returns {ContentPath[]}
|
||||
*/
|
||||
function resolvePathSymlinks(contentPath) {
|
||||
let paths = [contentPath]
|
||||
|
||||
try {
|
||||
let resolvedPath = fs.realpathSync(contentPath.base)
|
||||
if (resolvedPath !== contentPath.base) {
|
||||
paths.push({
|
||||
...contentPath,
|
||||
base: resolvedPath,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// TODO: log this?
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} context
|
||||
* @param {ContentPath[]} candidateFiles
|
||||
* @param {Map<string, number>} fileModifiedMap
|
||||
* @returns {{ content: string, extension: string }[]}
|
||||
*/
|
||||
export function resolvedChangedContent(context, candidateFiles, fileModifiedMap) {
|
||||
let changedContent = context.tailwindConfig.content.files
|
||||
.filter((item) => typeof item.raw === 'string')
|
||||
.map(({ raw, extension = 'html' }) => ({ content: raw, extension }))
|
||||
|
||||
for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) {
|
||||
let content = fs.readFileSync(changedFile, 'utf8')
|
||||
let extension = path.extname(changedFile).slice(1)
|
||||
changedContent.push({ content, extension })
|
||||
}
|
||||
|
||||
return changedContent
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ContentPath[]} candidateFiles
|
||||
* @param {Map<string, number>} fileModifiedMap
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function resolveChangedFiles(candidateFiles, fileModifiedMap) {
|
||||
let paths = candidateFiles.map((contentPath) => contentPath.pattern)
|
||||
|
||||
let changedFiles = new Set()
|
||||
env.DEBUG && console.time('Finding changed files')
|
||||
let files = fastGlob.sync(paths, { absolute: true })
|
||||
for (let file of files) {
|
||||
let prevModified = fileModifiedMap.has(file) ? fileModifiedMap.get(file) : -Infinity
|
||||
let modified = fs.statSync(file).mtimeMs
|
||||
|
||||
if (modified > prevModified) {
|
||||
changedFiles.add(file)
|
||||
fileModifiedMap.set(file, modified)
|
||||
}
|
||||
}
|
||||
env.DEBUG && console.timeEnd('Finding changed files')
|
||||
return changedFiles
|
||||
}
|
||||
|
|
@ -1000,6 +1000,10 @@ export function getContext(
|
|||
|
||||
let context = createContext(tailwindConfig, [], root)
|
||||
|
||||
Object.assign(context, {
|
||||
userConfigPath,
|
||||
})
|
||||
|
||||
trackModified([...contextDependencies], getFileModifiedMap(context))
|
||||
|
||||
// ---
|
||||
|
|
|
|||
|
|
@ -1,22 +1,14 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import fastGlob from 'fast-glob'
|
||||
import LRU from 'quick-lru'
|
||||
import normalizePath from 'normalize-path'
|
||||
|
||||
import hash from '../util/hashConfig'
|
||||
import getModuleDependencies from '../lib/getModuleDependencies'
|
||||
|
||||
import resolveConfig from '../public/resolve-config'
|
||||
|
||||
import resolveConfigPath from '../util/resolveConfigPath'
|
||||
|
||||
import { env } from './sharedState'
|
||||
|
||||
import { getContext, getFileModifiedMap } from './setupContextUtils'
|
||||
import parseDependency from '../util/parseDependency'
|
||||
import { validateConfig } from '../util/validateConfig.js'
|
||||
import { parseCandidateFiles, resolvedChangedContent } from './content.js'
|
||||
|
||||
let configPathCache = new LRU({ maxSize: 100 })
|
||||
|
||||
|
|
@ -27,9 +19,7 @@ function getCandidateFiles(context, tailwindConfig) {
|
|||
return candidateFilesCache.get(context)
|
||||
}
|
||||
|
||||
let candidateFiles = tailwindConfig.content.files
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((contentPath) => normalizePath(contentPath))
|
||||
let candidateFiles = parseCandidateFiles(context, tailwindConfig)
|
||||
|
||||
return candidateFilesCache.set(context, candidateFiles).get(context)
|
||||
}
|
||||
|
|
@ -80,36 +70,6 @@ function getTailwindConfig(configOrPath) {
|
|||
return [newConfig, null, hash(newConfig), []]
|
||||
}
|
||||
|
||||
function resolvedChangedContent(context, candidateFiles, fileModifiedMap) {
|
||||
let changedContent = context.tailwindConfig.content.files
|
||||
.filter((item) => typeof item.raw === 'string')
|
||||
.map(({ raw, extension = 'html' }) => ({ content: raw, extension }))
|
||||
|
||||
for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) {
|
||||
let content = fs.readFileSync(changedFile, 'utf8')
|
||||
let extension = path.extname(changedFile).slice(1)
|
||||
changedContent.push({ content, extension })
|
||||
}
|
||||
return changedContent
|
||||
}
|
||||
|
||||
function resolveChangedFiles(candidateFiles, fileModifiedMap) {
|
||||
let changedFiles = new Set()
|
||||
env.DEBUG && console.time('Finding changed files')
|
||||
let files = fastGlob.sync(candidateFiles, { absolute: true })
|
||||
for (let file of files) {
|
||||
let prevModified = fileModifiedMap.has(file) ? fileModifiedMap.get(file) : -Infinity
|
||||
let modified = fs.statSync(file).mtimeMs
|
||||
|
||||
if (modified > prevModified) {
|
||||
changedFiles.add(file)
|
||||
fileModifiedMap.set(file, modified)
|
||||
}
|
||||
}
|
||||
env.DEBUG && console.timeEnd('Finding changed files')
|
||||
return changedFiles
|
||||
}
|
||||
|
||||
// DISABLE_TOUCH = TRUE
|
||||
|
||||
// Retrieve an existing context from cache if possible (since contexts are unique per
|
||||
|
|
@ -161,9 +121,8 @@ export default function setupTrackingContext(configOrPath) {
|
|||
let fileModifiedMap = getFileModifiedMap(context)
|
||||
|
||||
// Add template paths as postcss dependencies.
|
||||
for (let fileOrGlob of candidateFiles) {
|
||||
let dependency = parseDependency(fileOrGlob)
|
||||
if (dependency) {
|
||||
for (let contentPath of candidateFiles) {
|
||||
for (let dependency of parseDependency(contentPath)) {
|
||||
registerDependency(dependency)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,9 +56,11 @@ export function normalizeConfig(config) {
|
|||
|
||||
// When `config.content` is an object
|
||||
if (typeof config.content === 'object' && config.content !== null) {
|
||||
// Only `files`, `extract` and `transform` can exist in `config.content`
|
||||
// Only `files`, `relative`, `extract`, and `transform` can exist in `config.content`
|
||||
if (
|
||||
Object.keys(config.content).some((key) => !['files', 'extract', 'transform'].includes(key))
|
||||
Object.keys(config.content).some(
|
||||
(key) => !['files', 'relative', 'extract', 'transform'].includes(key)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -112,6 +114,14 @@ export function normalizeConfig(config) {
|
|||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// `config.content.relative` is optional and can be a boolean
|
||||
if (
|
||||
typeof config.content.relative !== 'boolean' &&
|
||||
typeof config.content.relative !== 'undefined'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -154,6 +164,16 @@ export function normalizeConfig(config) {
|
|||
|
||||
// Normalize the `content`
|
||||
config.content = {
|
||||
relative: (() => {
|
||||
let { content } = config
|
||||
|
||||
if (content?.relative) {
|
||||
return content.relative
|
||||
}
|
||||
|
||||
return config.future?.relativeContentPathsByDefault ?? false
|
||||
})(),
|
||||
|
||||
files: (() => {
|
||||
let { content, purge } = config
|
||||
|
||||
|
|
|
|||
|
|
@ -1,49 +1,44 @@
|
|||
import isGlob from 'is-glob'
|
||||
import globParent from 'glob-parent'
|
||||
import path from 'path'
|
||||
// @ts-check
|
||||
|
||||
// Based on `glob-base`
|
||||
// https://github.com/micromatch/glob-base/blob/master/index.js
|
||||
function parseGlob(pattern) {
|
||||
let glob = pattern
|
||||
let base = globParent(pattern)
|
||||
/**
|
||||
* @typedef {{type: 'dependency', file: string} | {type: 'dir-dependency', dir: string, glob: string}} Dependency
|
||||
*/
|
||||
|
||||
if (base !== '.') {
|
||||
glob = pattern.substr(base.length)
|
||||
if (glob.charAt(0) === '/') {
|
||||
glob = glob.substr(1)
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {import('../lib/content.js').ContentPath} contentPath
|
||||
* @returns {Dependency[]}
|
||||
*/
|
||||
export default function parseDependency(contentPath) {
|
||||
if (contentPath.ignore) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (glob.substr(0, 2) === './') {
|
||||
glob = glob.substr(2)
|
||||
}
|
||||
if (glob.charAt(0) === '/') {
|
||||
glob = glob.substr(1)
|
||||
if (!contentPath.glob) {
|
||||
return [
|
||||
{
|
||||
type: 'dependency',
|
||||
file: contentPath.base,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return { base, glob }
|
||||
}
|
||||
|
||||
export default function parseDependency(normalizedFileOrGlob) {
|
||||
if (normalizedFileOrGlob.startsWith('!')) {
|
||||
return null
|
||||
}
|
||||
|
||||
let message
|
||||
|
||||
if (isGlob(normalizedFileOrGlob)) {
|
||||
let { base, glob } = parseGlob(normalizedFileOrGlob)
|
||||
message = { type: 'dir-dependency', dir: path.resolve(base), glob }
|
||||
} else {
|
||||
message = { type: 'dependency', file: path.resolve(normalizedFileOrGlob) }
|
||||
}
|
||||
|
||||
// rollup-plugin-postcss does not support dir-dependency messages
|
||||
// but directories can be watched in the same way as files
|
||||
if (message.type === 'dir-dependency' && process.env.ROLLUP_WATCH === 'true') {
|
||||
message = { type: 'dependency', file: message.dir }
|
||||
}
|
||||
|
||||
return message
|
||||
if (process.env.ROLLUP_WATCH === 'true') {
|
||||
// rollup-plugin-postcss does not support dir-dependency messages
|
||||
// but directories can be watched in the same way as files
|
||||
return [
|
||||
{
|
||||
type: 'dependency',
|
||||
file: contentPath.base,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'dir-dependency',
|
||||
dir: contentPath.base,
|
||||
glob: contentPath.glob,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
24
src/util/parseGlob.js
Normal file
24
src/util/parseGlob.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import globParent from 'glob-parent'
|
||||
|
||||
// Based on `glob-base`
|
||||
// https://github.com/micromatch/glob-base/blob/master/index.js
|
||||
export function parseGlob(pattern) {
|
||||
let glob = pattern
|
||||
let base = globParent(pattern)
|
||||
|
||||
if (base !== '.') {
|
||||
glob = pattern.substr(base.length)
|
||||
if (glob.charAt(0) === '/') {
|
||||
glob = glob.substr(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (glob.substr(0, 2) === './') {
|
||||
glob = glob.substr(2)
|
||||
}
|
||||
if (glob.charAt(0) === '/') {
|
||||
glob = glob.substr(1)
|
||||
}
|
||||
|
||||
return { base, glob }
|
||||
}
|
||||
|
|
@ -105,6 +105,7 @@ it('should keep content files with globs', () => {
|
|||
|
||||
expect(normalizeConfig(resolveConfig(config)).content).toEqual({
|
||||
files: ['./example-folder/**/*.{html,js}'],
|
||||
relative: false,
|
||||
extract: {},
|
||||
transform: {},
|
||||
})
|
||||
|
|
@ -129,6 +130,7 @@ it('should warn when we detect invalid globs with incorrect brace expansion', ()
|
|||
'./{example-folder}/**/*.{html}',
|
||||
'./example-folder/**/*.{html}',
|
||||
],
|
||||
relative: false,
|
||||
extract: {},
|
||||
transform: {},
|
||||
})
|
||||
|
|
|
|||
13
types/config.d.ts
vendored
13
types/config.d.ts
vendored
|
|
@ -31,6 +31,7 @@ type ContentConfig =
|
|||
| (FilePath | RawFile)[]
|
||||
| {
|
||||
files: (FilePath | RawFile)[]
|
||||
relative?: boolean
|
||||
extract?: ExtractorFn | { [extension: string]: ExtractorFn }
|
||||
transform?: TransformerFn | { [extension: string]: TransformerFn }
|
||||
}
|
||||
|
|
@ -56,20 +57,24 @@ type SafelistConfig =
|
|||
type PresetsConfig = Config[]
|
||||
|
||||
// Future related config
|
||||
type FutureConfigValues = never // Replace with 'future-feature-1' | 'future-feature-2'
|
||||
type FutureConfigValues =
|
||||
| 'hoverOnlyWhenSupported'
|
||||
| 'respectDefaultRingColorOpacity'
|
||||
| 'disableColorOpacityUtilitiesByDefault'
|
||||
| 'relativeContentPathsByDefault'
|
||||
type FutureConfig = Expand<'all' | Partial<Record<FutureConfigValues, boolean>>> | []
|
||||
|
||||
// Experimental related config
|
||||
type ExperimentalConfigValues = 'optimizeUniversalDefaults' // Replace with 'experimental-feature-1' | 'experimental-feature-2'
|
||||
type ExperimentalConfigValues = 'optimizeUniversalDefaults' | 'matchVariant'
|
||||
type ExperimentalConfig = Expand<'all' | Partial<Record<ExperimentalConfigValues, boolean>>> | []
|
||||
|
||||
// DarkMode related config
|
||||
type DarkModeConfig =
|
||||
// Use the `media` query strategy.
|
||||
| 'media'
|
||||
// Use the `class` stategy, which requires a `.dark` class on the `html`.
|
||||
// Use the `class` strategy, which requires a `.dark` class on the `html`.
|
||||
| 'class'
|
||||
// Use the `class` stategy with a custom class instead of `.dark`.
|
||||
// Use the `class` strategy with a custom class instead of `.dark`.
|
||||
| ['class', string]
|
||||
|
||||
type Screen = { raw: string } | { min: string } | { max: string } | { min: string; max: string }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue