From 3c07c8b92d608c043ad81606f1c6243670629af3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 16 Sep 2022 13:59:56 -0400 Subject: [PATCH] Normalize and resolve content paths earlier Not just for PostCSS dependencies --- src/lib/content.js | 166 ++++++++++++++++++++++++++++++++ src/lib/setupTrackingContext.js | 39 +------- src/util/parseDependency.js | 120 ++++++----------------- src/util/parseGlob.js | 24 +++++ 4 files changed, 226 insertions(+), 123 deletions(-) create mode 100644 src/lib/content.js create mode 100644 src/util/parseGlob.js diff --git a/src/lib/content.js b/src/lib/content.js new file mode 100644 index 000000000..9bc8fb12b --- /dev/null +++ b/src/lib/content.js @@ -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} 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} fileModifiedMap + * @returns {Set} + */ +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 +} diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 7ebc11044..0f1526f23 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -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) } } diff --git a/src/util/parseDependency.js b/src/util/parseDependency.js index 4b862be4c..5847056e7 100644 --- a/src/util/parseDependency.js +++ b/src/util/parseDependency.js @@ -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, + }, + ] } diff --git a/src/util/parseGlob.js b/src/util/parseGlob.js new file mode 100644 index 000000000..5c03f413d --- /dev/null +++ b/src/util/parseGlob.js @@ -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 } +}