Normalize and resolve content paths earlier

Not just for PostCSS dependencies
This commit is contained in:
Jordan Pittman 2022-09-16 13:59:56 -04:00
parent 2102566df8
commit 3c07c8b92d
4 changed files with 226 additions and 123 deletions

166
src/lib/content.js Normal file
View file

@ -0,0 +1,166 @@
// @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 { flagEnabled } from '../featureFlags.js'
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
*/
/**
* 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
return files.flatMap((contentPath) => parseContentPath(context, contentPath))
}
/**
*
* @param {any} context
* @param {RawFile | FilePath} filePath
* @returns {ContentPath[]}
*/
function parseContentPath(context, filePath) {
if (typeof filePath !== 'string') {
return []
}
filePath = normalizePath(filePath)
/** @type {ContentPath[]} */
let paths = []
if (isGlob(filePath)) {
let { base, glob } = parseGlob(filePath)
paths.push({ original: filePath, base: base, glob })
} else {
paths.push({ original: filePath, base: filePath, glob: null })
}
paths = resolveRelativePaths(context, paths)
paths = paths.flatMap(resolvePathSymlinks)
return paths
}
/**
* 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 &&
flagEnabled(context.tailwindConfig, 'resolveContentRelativeToConfig')
) {
resolveFrom = [path.dirname(context.userConfigPath)]
}
return contentPaths.map((contentPath) =>
Object.assign(contentPath, {
base: path.resolve(...resolveFrom, contentPath.base),
})
)
}
/**
* 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) {
try {
let newPath = fs.realpathSync(contentPath.base, { encoding: 'utf8' })
if (newPath !== contentPath.base) {
return [contentPath, { ...contentPath, base: newPath }]
}
} catch {
// TODO: log this?
}
return [contentPath]
}
/**
* @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.original.startsWith('!')
? contentPath.original
: contentPath.glob
? `${contentPath.base}/${contentPath.glob}`
: contentPath.base
)
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

@ -17,6 +17,7 @@ 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 +28,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 +79,6 @@ function getTailwindConfig(configOrPath, inputPath) {
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,8 +130,8 @@ export default function setupTrackingContext(configOrPath) {
let fileModifiedMap = getFileModifiedMap(context)
// Add template paths as postcss dependencies.
for (let fileOrGlob of candidateFiles) {
for (let dependency of parseDependency(context, fileOrGlob)) {
for (let contentPath of candidateFiles) {
for (let dependency of parseDependency(contentPath)) {
registerDependency(dependency)
}
}

View file

@ -1,100 +1,44 @@
import isGlob from 'is-glob'
import globParent from 'glob-parent'
import fs from 'fs'
import path from 'path'
import { flagEnabled } from '../featureFlags'
// @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.original.startsWith('!')) {
return []
}
if (glob.substr(0, 2) === './') {
glob = glob.substr(2)
}
if (glob.charAt(0) === '/') {
glob = glob.substr(1)
}
return { base, glob }
}
function resolvePathSymlinks(pathDesc) {
try {
let newPath = fs.realpathSync(pathDesc.base, { encoding: 'utf8' })
if (newPath !== pathDesc.base) {
return [pathDesc, { ...pathDesc, base: newPath }]
}
} catch {
// TODO: log this?
}
return [pathDesc]
}
function toDependency(pathDesc) {
if (!pathDesc.glob) {
return {
type: 'dependency',
file: pathDesc.base,
}
if (!contentPath.glob) {
return [
{
type: 'dependency',
file: contentPath.base,
},
]
}
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: pathDesc.base,
}
return [
{
type: 'dependency',
file: contentPath.base,
},
]
}
return {
type: 'dir-dependency',
dir: pathDesc.base,
glob: pathDesc.glob,
}
}
export default function parseDependency(context, normalizedFileOrGlob) {
if (normalizedFileOrGlob.startsWith('!')) {
return []
}
let paths = []
if (isGlob(normalizedFileOrGlob)) {
let { base, glob } = parseGlob(normalizedFileOrGlob)
paths.push({ base: base, glob })
} else {
paths.push({ base: normalizedFileOrGlob, glob: null })
}
// Resolve base paths relative to the config file or current working directory
let resolveFrom = flagEnabled(context.tailwindConfig, 'resolveContentRelativeToConfig')
? [context.userConfigPath ?? process.cwd()]
: [process.cwd()]
paths = paths.map((pathDesc) =>
Object.assign(pathDesc, {
base: path.resolve(...resolveFrom, pathDesc.base),
})
)
// 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.
paths = paths.flatMap(resolvePathSymlinks)
return paths.map(toDependency)
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 }
}