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:
Jordan Pittman 2022-09-23 08:09:02 -04:00 • committed by GitHub
parent 7ea02c2d34
commit bf44941049
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 2121 additions and 128 deletions

View file

@ -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:

View file

@ -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

View file

@ -0,0 +1 @@
*.tailwind.config.js

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -0,0 +1,5 @@
let path = require('path')
module.exports = {
plugins: [require(path.resolve(__dirname, '..', '..'))],
}

View file

@ -0,0 +1 @@
@tailwind utilities;

View file

@ -0,0 +1 @@
// content-[real-static-negative]

View file

@ -0,0 +1 @@
// content-[real-dynamic-negative]

View file

@ -0,0 +1 @@
<!-- content-[real-static-positive] -->

View file

@ -0,0 +1 @@
// content-[real-dynamic-positive]

View file

@ -0,0 +1 @@
// content-[resolved-static-negative]

View file

@ -0,0 +1 @@
// content-[resolved-dynamic-negative]

View file

@ -0,0 +1 @@
<!-- content-[resolved-static-positive] -->

View file

@ -0,0 +1 @@
// content-[resolved-dynamic-positive]

View 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'))
}

View 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);
}
`)
})

View 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()
}
},
}

View file

@ -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]
})()

View file

@ -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"
}

View file

@ -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) {

View file

@ -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
View 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
}

View file

@ -1000,6 +1000,10 @@ export function getContext(
let context = createContext(tailwindConfig, [], root)
Object.assign(context, {
userConfigPath,
})
trackModified([...contextDependencies], getFileModifiedMap(context))
// ---

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
View 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 }
}

View file

@ -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
View file

@ -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 }