From 182e3dc480d10911ae719bcb64912e7046918e0f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 11 Jul 2022 12:44:18 -0400 Subject: [PATCH 01/13] Update lockfile --- .../tailwindcss-cli/package-lock.json | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/integrations/tailwindcss-cli/package-lock.json b/integrations/tailwindcss-cli/package-lock.json index e36028945..1bfff83a7 100644 --- a/integrations/tailwindcss-cli/package-lock.json +++ b/integrations/tailwindcss-cli/package-lock.json @@ -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" } From a3b42e4fec9ba916bd1bc58f617c26b65c7647c5 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Sep 2022 13:55:11 -0400 Subject: [PATCH 02/13] Tweak formatting --- src/featureFlags.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/featureFlags.js b/src/featureFlags.js index 5e9d73583..bd75108ea 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -11,7 +11,11 @@ let featureFlags = { 'respectDefaultRingColorOpacity', 'disableColorOpacityUtilitiesByDefault', ], - experimental: ['optimizeUniversalDefaults', 'matchVariant' /* , 'variantGrouping' */], + experimental: [ + 'optimizeUniversalDefaults', + 'matchVariant', + // 'variantGrouping', + ], } export function flagEnabled(config, flag) { From d18fd513a4a09880e878252d76db59b5055c4a9c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Sep 2022 13:55:15 -0400 Subject: [PATCH 03/13] Add option to load config file relative to the input file --- src/featureFlags.js | 1 + src/lib/setupTrackingContext.js | 6 ++--- src/util/resolveConfigPath.js | 44 ++++++++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/featureFlags.js b/src/featureFlags.js index bd75108ea..fef84b149 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -15,6 +15,7 @@ let featureFlags = { 'optimizeUniversalDefaults', 'matchVariant', // 'variantGrouping', + 'resolveConfigRelativeToInput', ], } diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 37b42cdba..14cd92984 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -35,8 +35,8 @@ function getCandidateFiles(context, tailwindConfig) { } // Get the config object based on a path -function getTailwindConfig(configOrPath) { - let userConfigPath = resolveConfigPath(configOrPath) +function getTailwindConfig(configOrPath, inputPath) { + let userConfigPath = resolveConfigPath(configOrPath, inputPath) if (userConfigPath !== null) { let [prevConfig, prevConfigHash, prevDeps, prevModified] = @@ -119,7 +119,7 @@ export default function setupTrackingContext(configOrPath) { return ({ tailwindDirectives, registerDependency }) => { return (root, result) => { let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] = - getTailwindConfig(configOrPath) + getTailwindConfig(configOrPath, result.opts.from) let contextDependencies = new Set(configDependencies) diff --git a/src/util/resolveConfigPath.js b/src/util/resolveConfigPath.js index 646a46fd4..f622f75f0 100644 --- a/src/util/resolveConfigPath.js +++ b/src/util/resolveConfigPath.js @@ -1,5 +1,6 @@ import fs from 'fs' import path from 'path' +import { flagEnabled } from '../featureFlags.js' function isObject(value) { return typeof value === 'object' && value !== null @@ -13,7 +14,42 @@ function isString(value) { return typeof value === 'string' || value instanceof String } -export default function resolveConfigPath(pathOrConfig) { +/** + * This will take a possibly-relative path to a config and resolve + * it relative to the input path IF that config file exists and + * has the `resolveConfigRelativeToInput` flag enabled. + * + * If that file does not exist, or the flag is disabled, it will + * resolve the path relative to the current working directory. + * + * @param {string|undefined} inputPath + * @param {string} configPath + * @returns {string} + */ +function pickResolvedPath(configPath, inputPath) { + if (path.isAbsolute(configPath)) { + return configPath + } + + if (inputPath) { + try { + // Use require.resolve so we can find config file in parent directories + let resolvedPath = require.resolve(configPath, { + paths: [inputPath], + }) + + let maybeConfig = require(resolvedPath) + + if (typeof maybeConfig === 'object' && flagEnabled(maybeConfig, 'resolveConfigRelativeToInput')) { + return resolvedPath + } + } catch (e) {} + } + + return path.resolve(configPath) +} + +export default function resolveConfigPath(pathOrConfig, inputPath) { // require('tailwindcss')({ theme: ..., variants: ... }) if (isObject(pathOrConfig) && pathOrConfig.config === undefined && !isEmpty(pathOrConfig)) { return null @@ -25,7 +61,7 @@ export default function resolveConfigPath(pathOrConfig) { pathOrConfig.config !== undefined && isString(pathOrConfig.config) ) { - return path.resolve(pathOrConfig.config) + return pickResolvedPath(pathOrConfig.config, inputPath) } // require('tailwindcss')({ config: { theme: ..., variants: ... } }) @@ -39,13 +75,13 @@ export default function resolveConfigPath(pathOrConfig) { // require('tailwindcss')('custom-config.js') if (isString(pathOrConfig)) { - return path.resolve(pathOrConfig) + return pickResolvedPath(pathOrConfig, inputPath) } // require('tailwindcss') for (const configFile of ['./tailwind.config.js', './tailwind.config.cjs']) { try { - const configPath = path.resolve(configFile) + const configPath = pickResolvedPath(configFile, inputPath) fs.accessSync(configPath) return configPath } catch (err) {} From 137a85c0ff26ee32874d60cc647009c015218eb5 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Sep 2022 16:24:33 -0400 Subject: [PATCH 04/13] Refactor `parseDependency` --- src/lib/setupTrackingContext.js | 3 +- src/util/parseDependency.js | 51 +++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 14cd92984..28656bb57 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -162,8 +162,7 @@ export default function setupTrackingContext(configOrPath) { // Add template paths as postcss dependencies. for (let fileOrGlob of candidateFiles) { - let dependency = parseDependency(fileOrGlob) - if (dependency) { + for (let dependency of parseDependency(fileOrGlob)) { registerDependency(dependency) } } diff --git a/src/util/parseDependency.js b/src/util/parseDependency.js index 1051df473..bb9118cbe 100644 --- a/src/util/parseDependency.js +++ b/src/util/parseDependency.js @@ -1,6 +1,8 @@ import isGlob from 'is-glob' import globParent from 'glob-parent' +import fs from 'fs' import path from 'path' +import { flagEnabled } from '../featureFlags' // Based on `glob-base` // https://github.com/micromatch/glob-base/blob/master/index.js @@ -25,25 +27,50 @@ function parseGlob(pattern) { return { base, glob } } -export default function parseDependency(normalizedFileOrGlob) { - if (normalizedFileOrGlob.startsWith('!')) { - return null +function toDependency(pathDesc) { + if (!pathDesc.glob) { + return { + type: 'dependency', + file: pathDesc.base, + } } - let 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: pathDesc.base, + } + } + + return { + type: 'dir-dependency', + dir: pathDesc.base, + glob: pathDesc.glob, + } +} + +export default function parseDependency(normalizedFileOrGlob) { + if (normalizedFileOrGlob.startsWith('!')) { + return [] + } + + let paths = [] if (isGlob(normalizedFileOrGlob)) { let { base, glob } = parseGlob(normalizedFileOrGlob) - message = { type: 'dir-dependency', dir: path.resolve(base), glob } + + paths.push({ base: base, glob }) } else { - message = { type: 'dependency', file: path.resolve(normalizedFileOrGlob) } + paths.push({ base: normalizedFileOrGlob, glob: null }) } - // 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 } - } + paths = paths.map((pathDesc) => + Object.assign(pathDesc, { + base: path.resolve(pathDesc.base), + }) + ) - return message + return paths.map(toDependency) } From 14c39f4d44ebf251dcb133d7c33a7479c70a78c1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Sep 2022 16:26:37 -0400 Subject: [PATCH 05/13] Add flag to resolve content relative to the config file --- src/featureFlags.js | 1 + src/lib/setupContextUtils.js | 2 ++ src/lib/setupTrackingContext.js | 2 +- src/util/parseDependency.js | 9 +++++++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/featureFlags.js b/src/featureFlags.js index fef84b149..7616dbd64 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -15,6 +15,7 @@ let featureFlags = { 'optimizeUniversalDefaults', 'matchVariant', // 'variantGrouping', + 'resolveContentRelativeToConfig', 'resolveConfigRelativeToInput', ], } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 2b8c09502..67587459b 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -1000,6 +1000,8 @@ export function getContext( let context = createContext(tailwindConfig, [], root) + Object.assign(context, { userConfigPath }) + trackModified([...contextDependencies], getFileModifiedMap(context)) // --- diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 28656bb57..7ebc11044 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -162,7 +162,7 @@ export default function setupTrackingContext(configOrPath) { // Add template paths as postcss dependencies. for (let fileOrGlob of candidateFiles) { - for (let dependency of parseDependency(fileOrGlob)) { + for (let dependency of parseDependency(context, fileOrGlob)) { registerDependency(dependency) } } diff --git a/src/util/parseDependency.js b/src/util/parseDependency.js index bb9118cbe..1ae811659 100644 --- a/src/util/parseDependency.js +++ b/src/util/parseDependency.js @@ -51,7 +51,7 @@ function toDependency(pathDesc) { } } -export default function parseDependency(normalizedFileOrGlob) { +export default function parseDependency(context, normalizedFileOrGlob) { if (normalizedFileOrGlob.startsWith('!')) { return [] } @@ -66,9 +66,14 @@ export default function parseDependency(normalizedFileOrGlob) { 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(pathDesc.base), + base: path.resolve(...resolveFrom, pathDesc.base), }) ) From 2102566df8d3d97348c43320a6e5a38c99256efa Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Sep 2022 16:22:37 -0400 Subject: [PATCH 06/13] Resolve symlinks for paths --- src/util/parseDependency.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/util/parseDependency.js b/src/util/parseDependency.js index 1ae811659..4b862be4c 100644 --- a/src/util/parseDependency.js +++ b/src/util/parseDependency.js @@ -27,6 +27,19 @@ function parseGlob(pattern) { 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 { @@ -77,5 +90,11 @@ export default function parseDependency(context, normalizedFileOrGlob) { }) ) + // 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) } From 3c07c8b92d608c043ad81606f1c6243670629af3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 16 Sep 2022 13:59:56 -0400 Subject: [PATCH 07/13] 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 } +} From 788e283379ad6f0b0e05caa6e40fb9812ae73a5a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 16 Sep 2022 16:18:12 -0400 Subject: [PATCH 08/13] wip --- src/lib/content.js | 109 ++++++++++++++++++++++++------------ src/util/parseDependency.js | 2 +- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/src/lib/content.js b/src/lib/content.js index 9bc8fb12b..da153dddc 100644 --- a/src/lib/content.js +++ b/src/lib/content.js @@ -17,6 +17,8 @@ import { env } from './sharedState' * @property {string} original * @property {string} base * @property {string | null} glob + * @property {boolean} ignore + * @property {string} pattern */ /** @@ -33,37 +35,73 @@ import { env } from './sharedState' export function parseCandidateFiles(context, tailwindConfig) { let files = tailwindConfig.content.files - return files.flatMap((contentPath) => parseContentPath(context, contentPath)) + // 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 {any} context - * @param {RawFile | FilePath} filePath - * @returns {ContentPath[]} + * @param {string} filePath + * @param {boolean} ignore + * @returns {ContentPath} */ -function parseContentPath(context, filePath) { - if (typeof filePath !== 'string') { - return [] +function parseFilePath(filePath, ignore) { + let contentPath = { + original: filePath, + base: filePath, + ignore, + pattern: filePath, + glob: null, } - 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 }) + Object.assign(contentPath, parseGlob(filePath)) } - paths = resolveRelativePaths(context, paths) - paths = paths.flatMap(resolvePathSymlinks) + return contentPath +} - return paths +/** + * + * @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 } /** @@ -85,11 +123,11 @@ function resolveRelativePaths(context, contentPaths) { resolveFrom = [path.dirname(context.userConfigPath)] } - return contentPaths.map((contentPath) => - Object.assign(contentPath, { - base: path.resolve(...resolveFrom, contentPath.base), - }) - ) + return contentPaths.map((contentPath) => { + contentPath.base = path.resolve(...resolveFrom, contentPath.base) + + return contentPath + }) } /** @@ -102,16 +140,21 @@ function resolveRelativePaths(context, contentPaths) { * @returns {ContentPath[]} */ function resolvePathSymlinks(contentPath) { + let paths = [contentPath] + try { - let newPath = fs.realpathSync(contentPath.base, { encoding: 'utf8' }) - if (newPath !== contentPath.base) { - return [contentPath, { ...contentPath, base: newPath }] + let resolvedPath = fs.realpathSync(contentPath.base) + if (resolvedPath !== contentPath.base) { + paths.push({ + ...contentPath, + base: resolvedPath, + }) } } catch { // TODO: log this? } - return [contentPath] + return paths } /** @@ -141,13 +184,7 @@ export function resolvedChangedContent(context, candidateFiles, 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 paths = candidateFiles.map((contentPath) => contentPath.pattern) let changedFiles = new Set() env.DEBUG && console.time('Finding changed files') diff --git a/src/util/parseDependency.js b/src/util/parseDependency.js index 5847056e7..f26eb1a29 100644 --- a/src/util/parseDependency.js +++ b/src/util/parseDependency.js @@ -10,7 +10,7 @@ * @returns {Dependency[]} */ export default function parseDependency(contentPath) { - if (contentPath.original.startsWith('!')) { + if (contentPath.ignore) { return [] } From 6bd3327d8e58689658414b4eb671e0d3f36807ac Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 20 Sep 2022 10:05:56 -0400 Subject: [PATCH 09/13] wip wip wip wip --- .../tailwindcss-cli/tests/cli.test.js | 1 + src/cli.js | 59 +++++++++++++++---- src/featureFlags.js | 2 - src/lib/content.js | 5 +- src/lib/setupContextUtils.js | 8 ++- src/lib/setupTrackingContext.js | 21 +++---- src/util/resolveConfigPath.js | 22 ++++--- 7 files changed, 71 insertions(+), 47 deletions(-) diff --git a/integrations/tailwindcss-cli/tests/cli.test.js b/integrations/tailwindcss-cli/tests/cli.test.js index 9ebcd3012..e23b6c36d 100644 --- a/integrations/tailwindcss-cli/tests/cli.test.js +++ b/integrations/tailwindcss-cli/tests/cli.test.js @@ -459,6 +459,7 @@ describe('Build command', () => { -m, --minify Minify the output -c, --config Path to a custom config file --no-autoprefixer Disable autoprefixer + --experimental-contextual-paths Enable experimental contextual path resolution -h, --help Display usage information `) ) diff --git a/src/cli.js b/src/cli.js index 2851a79f1..5f4bad74a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -19,6 +19,8 @@ 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' +import resolveConfigPath from './util/resolveConfigPath.js' let env = { DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0', @@ -219,6 +221,10 @@ let commands = { type: Boolean, description: 'Disable autoprefixer', }, + '--experimental-contextual-paths': { + type: Boolean, + description: 'Enable experimental contextual path resolution', + }, '-c': '--config', '-i': '--input', '-o': '--output', @@ -429,11 +435,31 @@ async function build() { process.exit(9) } - let configPath = args['--config'] - ? args['--config'] - : ((defaultPath) => (fs.existsSync(defaultPath) ? defaultPath : null))( - path.resolve(`./${configs.tailwind}`) - ) + let configPath + + if (args['--config']) { + // The user has specified an exact config file to use + // So we want to use that one and only that one + configPath = path.resolve(args['--config']) + } else { + let postcssOptions = { + config: `./${configs.tailwind}`, + experimental: { + contextualPaths: args['--experimental-contextual-paths'], + }, + } + + // The user has not specified an exact config file to use + // So we want to find the closest one to the input file + // when experimental contextual paths are enabled + let maybeConfigPath = resolveConfigPath(postcssOptions, input ? path.resolve(input) : null) + + if (fs.existsSync(maybeConfigPath)) { + configPath = maybeConfigPath + } else { + configPath = null + } + } async function loadPostCssPlugins() { let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined @@ -551,14 +577,20 @@ 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 = { + userConfigPath: configPath, + postcssOptions: args['--experimental-contextual-paths'] + ? { + experimental: { + contextualPaths: true, + }, + } + : {}, + } + + let contentPaths = parseCandidateFiles(context, config) + + return contentPaths.map((contentPath) => contentPath.pattern) } function extractRawContent(config) { @@ -845,6 +877,7 @@ async function build() { * @return {{all: string[], dynamic: string[], static: string[]}} **/ function refreshContentPatterns(config) { + // TODO: This can be optimized since we're using `fastGlob.generateTasks` indirectly in extractFileGlobs let globs = extractFileGlobs(config) let tasks = fastGlob.generateTasks(globs, { absolute: true }) let dynamicPatterns = tasks.filter((task) => task.dynamic).flatMap((task) => task.patterns) diff --git a/src/featureFlags.js b/src/featureFlags.js index 7616dbd64..bd75108ea 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -15,8 +15,6 @@ let featureFlags = { 'optimizeUniversalDefaults', 'matchVariant', // 'variantGrouping', - 'resolveContentRelativeToConfig', - 'resolveConfigRelativeToInput', ], } diff --git a/src/lib/content.js b/src/lib/content.js index da153dddc..f0ceefa9f 100644 --- a/src/lib/content.js +++ b/src/lib/content.js @@ -116,10 +116,7 @@ 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') - ) { + if (context.userConfigPath && context.postcssOptions?.experimental?.contextualPaths) { resolveFrom = [path.dirname(context.userConfigPath)] } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 67587459b..ddaaa481c 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -940,7 +940,8 @@ export function getContext( tailwindConfig, userConfigPath, tailwindConfigHash, - contextDependencies + contextDependencies, + postcssOptions ) { let sourcePath = result.opts.from let isConfigFile = userConfigPath !== null @@ -1000,7 +1001,10 @@ export function getContext( let context = createContext(tailwindConfig, [], root) - Object.assign(context, { userConfigPath }) + Object.assign(context, { + userConfigPath, + postcssOptions, + }) trackModified([...contextDependencies], getFileModifiedMap(context)) diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 0f1526f23..eae375e98 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -1,19 +1,10 @@ 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' @@ -36,6 +27,7 @@ function getCandidateFiles(context, tailwindConfig) { // Get the config object based on a path function getTailwindConfig(configOrPath, inputPath) { let userConfigPath = resolveConfigPath(configOrPath, inputPath) + let postcssOptions = typeof configOrPath === 'object' && configOrPath !== null ? configOrPath : {} if (userConfigPath !== null) { let [prevConfig, prevConfigHash, prevDeps, prevModified] = @@ -55,7 +47,7 @@ function getTailwindConfig(configOrPath, inputPath) { // It hasn't changed (based on timestamps) if (!modified) { - return [prevConfig, userConfigPath, prevConfigHash, prevDeps] + return [prevConfig, userConfigPath, prevConfigHash, prevDeps, postcssOptions] } // It has changed (based on timestamps), or first run @@ -66,7 +58,7 @@ function getTailwindConfig(configOrPath, inputPath) { newConfig = validateConfig(newConfig) let newHash = hash(newConfig) configPathCache.set(userConfigPath, [newConfig, newHash, newDeps, newModified]) - return [newConfig, userConfigPath, newHash, newDeps] + return [newConfig, userConfigPath, newHash, newDeps, postcssOptions] } // It's a plain object, not a path @@ -76,7 +68,7 @@ function getTailwindConfig(configOrPath, inputPath) { newConfig = validateConfig(newConfig) - return [newConfig, null, hash(newConfig), []] + return [newConfig, null, hash(newConfig), [], postcssOptions] } // DISABLE_TOUCH = TRUE @@ -87,7 +79,7 @@ function getTailwindConfig(configOrPath, inputPath) { export default function setupTrackingContext(configOrPath) { return ({ tailwindDirectives, registerDependency }) => { return (root, result) => { - let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] = + let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies, postcssOptions] = getTailwindConfig(configOrPath, result.opts.from) let contextDependencies = new Set(configDependencies) @@ -116,7 +108,8 @@ export default function setupTrackingContext(configOrPath) { tailwindConfig, userConfigPath, tailwindConfigHash, - contextDependencies + contextDependencies, + postcssOptions ) let candidateFiles = getCandidateFiles(context, tailwindConfig) diff --git a/src/util/resolveConfigPath.js b/src/util/resolveConfigPath.js index f622f75f0..c2b8e862b 100644 --- a/src/util/resolveConfigPath.js +++ b/src/util/resolveConfigPath.js @@ -34,16 +34,10 @@ function pickResolvedPath(configPath, inputPath) { if (inputPath) { try { // Use require.resolve so we can find config file in parent directories - let resolvedPath = require.resolve(configPath, { - paths: [inputPath], + return require.resolve(configPath, { + paths: [path.dirname(inputPath)], }) - - let maybeConfig = require(resolvedPath) - - if (typeof maybeConfig === 'object' && flagEnabled(maybeConfig, 'resolveConfigRelativeToInput')) { - return resolvedPath - } - } catch (e) {} + } catch {} } return path.resolve(configPath) @@ -61,7 +55,11 @@ export default function resolveConfigPath(pathOrConfig, inputPath) { pathOrConfig.config !== undefined && isString(pathOrConfig.config) ) { - return pickResolvedPath(pathOrConfig.config, inputPath) + if (pathOrConfig.experimental?.contextualPaths) { + return pickResolvedPath(pathOrConfig.config, inputPath) + } + + return path.resolve(pathOrConfig.config) } // require('tailwindcss')({ config: { theme: ..., variants: ... } }) @@ -75,13 +73,13 @@ export default function resolveConfigPath(pathOrConfig, inputPath) { // require('tailwindcss')('custom-config.js') if (isString(pathOrConfig)) { - return pickResolvedPath(pathOrConfig, inputPath) + return path.resolve(pathOrConfig) } // require('tailwindcss') for (const configFile of ['./tailwind.config.js', './tailwind.config.cjs']) { try { - const configPath = pickResolvedPath(configFile, inputPath) + const configPath = path.resolve(configFile) fs.accessSync(configPath) return configPath } catch (err) {} From df35325e2748347ba556ef7099290ec7a283b3a5 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 21 Sep 2022 13:32:04 -0400 Subject: [PATCH 10/13] wip --- src/featureFlags.js | 1 + src/index.js | 5 +++++ src/lib/content.js | 2 +- src/lib/findAtConfigPath.js | 33 +++++++++++++++++++++++++++++++++ src/lib/setupContextUtils.js | 4 +--- src/lib/setupTrackingContext.js | 12 +++++------- src/util/normalizeConfig.js | 24 ++++++++++++++++++++++-- src/util/resolveConfigPath.js | 6 +++--- types/config.d.ts | 1 + 9 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 src/lib/findAtConfigPath.js diff --git a/src/featureFlags.js b/src/featureFlags.js index bd75108ea..a03916c5b 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -10,6 +10,7 @@ let featureFlags = { 'hoverOnlyWhenSupported', 'respectDefaultRingColorOpacity', 'disableColorOpacityUtilitiesByDefault', + 'relativeContentPathsByDefault', ], experimental: [ 'optimizeUniversalDefaults', diff --git a/src/index.js b/src/index.js index 9e9e291eb..7dc41c317 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import setupTrackingContext from './lib/setupTrackingContext' import processTailwindFeatures from './processTailwindFeatures' import { env } from './lib/sharedState' +import { findAtConfigPath } from './lib/findAtConfigPath' module.exports = function tailwindcss(configOrPath) { return { @@ -13,6 +14,10 @@ module.exports = function tailwindcss(configOrPath) { return root }, function (root, result) { + // Use the path for the `@config` directive if it exists, otherwise use the + // path for the file being processed + configOrPath = findAtConfigPath(root, result) ?? configOrPath + let context = setupTrackingContext(configOrPath) if (root.type === 'document') { diff --git a/src/lib/content.js b/src/lib/content.js index f0ceefa9f..96d03d345 100644 --- a/src/lib/content.js +++ b/src/lib/content.js @@ -116,7 +116,7 @@ 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.postcssOptions?.experimental?.contextualPaths) { + if (context.userConfigPath && context.tailwindConfig.content.relative) { resolveFrom = [path.dirname(context.userConfigPath)] } diff --git a/src/lib/findAtConfigPath.js b/src/lib/findAtConfigPath.js new file mode 100644 index 000000000..067e8e312 --- /dev/null +++ b/src/lib/findAtConfigPath.js @@ -0,0 +1,33 @@ +import path from 'path' + +/** + * Find the @config at-rule in the given CSS AST and return the relative path to the config file + * + * + * @param {import('postcss').Root} root + * @param {import('postcss').Result} result + */ +export function findAtConfigPath(root, result) { + let configPath = null + let relativeTo = root.source.input.file ?? result.opts.from ?? null + + root.walkAtRules('config', (rule) => { + if (relativeTo === undefined) { + throw rule.error( + 'The `@config` at-rule cannot be used without a `from` option being set on the PostCSS config.' + ) + } + + if (configPath) { + throw rule.error('Only `@config` at-rule is allowed per file.') + } + + configPath = rule.params.replace(/^['"]|['"]$/, '') + + if (path.isAbsolute(configPath)) { + throw rule.error('The `@config` at-rule cannot be used with an absolute path.') + } + }) + + return configPath ? path.resolve(path.dirname(relativeTo), configPath) : null +} diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index ddaaa481c..d15e8db8c 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -940,8 +940,7 @@ export function getContext( tailwindConfig, userConfigPath, tailwindConfigHash, - contextDependencies, - postcssOptions + contextDependencies ) { let sourcePath = result.opts.from let isConfigFile = userConfigPath !== null @@ -1003,7 +1002,6 @@ export function getContext( Object.assign(context, { userConfigPath, - postcssOptions, }) trackModified([...contextDependencies], getFileModifiedMap(context)) diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index eae375e98..8c68e9f18 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -27,7 +27,6 @@ function getCandidateFiles(context, tailwindConfig) { // Get the config object based on a path function getTailwindConfig(configOrPath, inputPath) { let userConfigPath = resolveConfigPath(configOrPath, inputPath) - let postcssOptions = typeof configOrPath === 'object' && configOrPath !== null ? configOrPath : {} if (userConfigPath !== null) { let [prevConfig, prevConfigHash, prevDeps, prevModified] = @@ -47,7 +46,7 @@ function getTailwindConfig(configOrPath, inputPath) { // It hasn't changed (based on timestamps) if (!modified) { - return [prevConfig, userConfigPath, prevConfigHash, prevDeps, postcssOptions] + return [prevConfig, userConfigPath, prevConfigHash, prevDeps] } // It has changed (based on timestamps), or first run @@ -58,7 +57,7 @@ function getTailwindConfig(configOrPath, inputPath) { newConfig = validateConfig(newConfig) let newHash = hash(newConfig) configPathCache.set(userConfigPath, [newConfig, newHash, newDeps, newModified]) - return [newConfig, userConfigPath, newHash, newDeps, postcssOptions] + return [newConfig, userConfigPath, newHash, newDeps] } // It's a plain object, not a path @@ -68,7 +67,7 @@ function getTailwindConfig(configOrPath, inputPath) { newConfig = validateConfig(newConfig) - return [newConfig, null, hash(newConfig), [], postcssOptions] + return [newConfig, null, hash(newConfig), []] } // DISABLE_TOUCH = TRUE @@ -79,7 +78,7 @@ function getTailwindConfig(configOrPath, inputPath) { export default function setupTrackingContext(configOrPath) { return ({ tailwindDirectives, registerDependency }) => { return (root, result) => { - let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies, postcssOptions] = + let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] = getTailwindConfig(configOrPath, result.opts.from) let contextDependencies = new Set(configDependencies) @@ -108,8 +107,7 @@ export default function setupTrackingContext(configOrPath) { tailwindConfig, userConfigPath, tailwindConfigHash, - contextDependencies, - postcssOptions + contextDependencies ) let candidateFiles = getCandidateFiles(context, tailwindConfig) diff --git a/src/util/normalizeConfig.js b/src/util/normalizeConfig.js index 2d23a864d..70ff792fb 100644 --- a/src/util/normalizeConfig.js +++ b/src/util/normalizeConfig.js @@ -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', 'extract', 'transform', 'relative'].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 diff --git a/src/util/resolveConfigPath.js b/src/util/resolveConfigPath.js index c2b8e862b..24fda7b5e 100644 --- a/src/util/resolveConfigPath.js +++ b/src/util/resolveConfigPath.js @@ -55,9 +55,9 @@ export default function resolveConfigPath(pathOrConfig, inputPath) { pathOrConfig.config !== undefined && isString(pathOrConfig.config) ) { - if (pathOrConfig.experimental?.contextualPaths) { - return pickResolvedPath(pathOrConfig.config, inputPath) - } + // if (pathOrConfig.experimental?.contextualPaths) { + // return pickResolvedPath(pathOrConfig.config, inputPath) + // } return path.resolve(pathOrConfig.config) } diff --git a/types/config.d.ts b/types/config.d.ts index 855b9bb02..61cd9546f 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -31,6 +31,7 @@ type ContentConfig = | (FilePath | RawFile)[] | { files: (FilePath | RawFile)[] + relative?: boolean extract?: ExtractorFn | { [extension: string]: ExtractorFn } transform?: TransformerFn | { [extension: string]: TransformerFn } } From 377f372536ce168de31796ee6f60c90ce6224e5d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 21 Sep 2022 14:20:29 -0400 Subject: [PATCH 11/13] wip cleanup wip --- src/cli.js | 44 ++++++------------------------------- src/lib/findAtConfigPath.js | 3 ++- 2 files changed, 9 insertions(+), 38 deletions(-) diff --git a/src/cli.js b/src/cli.js index 5f4bad74a..923be25c1 100644 --- a/src/cli.js +++ b/src/cli.js @@ -20,7 +20,6 @@ import normalizePath from 'normalize-path' import micromatch from 'micromatch' import { validateConfig } from './util/validateConfig.js' import { parseCandidateFiles } from './lib/content.js' -import resolveConfigPath from './util/resolveConfigPath.js' let env = { DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0', @@ -221,10 +220,6 @@ let commands = { type: Boolean, description: 'Disable autoprefixer', }, - '--experimental-contextual-paths': { - type: Boolean, - description: 'Enable experimental contextual path resolution', - }, '-c': '--config', '-i': '--input', '-o': '--output', @@ -435,31 +430,12 @@ async function build() { process.exit(9) } - let configPath - - if (args['--config']) { - // The user has specified an exact config file to use - // So we want to use that one and only that one - configPath = path.resolve(args['--config']) - } else { - let postcssOptions = { - config: `./${configs.tailwind}`, - experimental: { - contextualPaths: args['--experimental-contextual-paths'], - }, - } - - // The user has not specified an exact config file to use - // So we want to find the closest one to the input file - // when experimental contextual paths are enabled - let maybeConfigPath = resolveConfigPath(postcssOptions, input ? path.resolve(input) : null) - - if (fs.existsSync(maybeConfigPath)) { - configPath = maybeConfigPath - } else { - configPath = null - } - } + // TODO: Reference the @config path here if exists + let configPath = args['--config'] + ? args['--config'] + : ((defaultPath) => (fs.existsSync(defaultPath) ? defaultPath : null))( + path.resolve(`./${configs.tailwind}`) + ) async function loadPostCssPlugins() { let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined @@ -578,14 +554,8 @@ async function build() { function extractFileGlobs(config) { let context = { + tailwindConfig: config, userConfigPath: configPath, - postcssOptions: args['--experimental-contextual-paths'] - ? { - experimental: { - contextualPaths: true, - }, - } - : {}, } let contentPaths = parseCandidateFiles(context, config) diff --git a/src/lib/findAtConfigPath.js b/src/lib/findAtConfigPath.js index 067e8e312..81bf9cae4 100644 --- a/src/lib/findAtConfigPath.js +++ b/src/lib/findAtConfigPath.js @@ -22,7 +22,8 @@ export function findAtConfigPath(root, result) { throw rule.error('Only `@config` at-rule is allowed per file.') } - configPath = rule.params.replace(/^['"]|['"]$/, '') + configPath = rule.params.replace(/^['"]|['"]$/g, '') + rule.remove() if (path.isAbsolute(configPath)) { throw rule.error('The `@config` at-rule cannot be used with an absolute path.') From d76e5db551a902883ea21ac8e0a666831fa7d8f8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 22 Sep 2022 11:31:27 -0400 Subject: [PATCH 12/13] cleanup wip cleanup --- src/lib/findAtConfigPath.js | 14 +++++++++---- src/lib/setupTrackingContext.js | 6 +++--- src/util/normalizeConfig.js | 2 +- src/util/resolveConfigPath.js | 36 +-------------------------------- 4 files changed, 15 insertions(+), 43 deletions(-) diff --git a/src/lib/findAtConfigPath.js b/src/lib/findAtConfigPath.js index 81bf9cae4..15d28efa8 100644 --- a/src/lib/findAtConfigPath.js +++ b/src/lib/findAtConfigPath.js @@ -3,13 +3,12 @@ import path from 'path' /** * Find the @config at-rule in the given CSS AST and return the relative path to the config file * - * * @param {import('postcss').Root} root * @param {import('postcss').Result} result */ export function findAtConfigPath(root, result) { let configPath = null - let relativeTo = root.source.input.file ?? result.opts.from ?? null + let relativeTo = root.source?.input.file ?? result.opts.from ?? null root.walkAtRules('config', (rule) => { if (relativeTo === undefined) { @@ -22,12 +21,19 @@ export function findAtConfigPath(root, result) { throw rule.error('Only `@config` at-rule is allowed per file.') } - configPath = rule.params.replace(/^['"]|['"]$/g, '') - rule.remove() + let matches = rule.params.match(/(['"])(.*?)\1/) + if (!matches) { + throw rule.error( + 'The `@config` at-rule must be followed by a string containing the path to the config file.' + ) + } + configPath = matches[2] if (path.isAbsolute(configPath)) { throw rule.error('The `@config` at-rule cannot be used with an absolute path.') } + + rule.remove() }) return configPath ? path.resolve(path.dirname(relativeTo), configPath) : null diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 8c68e9f18..3e51bd9e1 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -25,8 +25,8 @@ function getCandidateFiles(context, tailwindConfig) { } // Get the config object based on a path -function getTailwindConfig(configOrPath, inputPath) { - let userConfigPath = resolveConfigPath(configOrPath, inputPath) +function getTailwindConfig(configOrPath) { + let userConfigPath = resolveConfigPath(configOrPath) if (userConfigPath !== null) { let [prevConfig, prevConfigHash, prevDeps, prevModified] = @@ -79,7 +79,7 @@ export default function setupTrackingContext(configOrPath) { return ({ tailwindDirectives, registerDependency }) => { return (root, result) => { let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] = - getTailwindConfig(configOrPath, result.opts.from) + getTailwindConfig(configOrPath) let contextDependencies = new Set(configDependencies) diff --git a/src/util/normalizeConfig.js b/src/util/normalizeConfig.js index 70ff792fb..baee48394 100644 --- a/src/util/normalizeConfig.js +++ b/src/util/normalizeConfig.js @@ -59,7 +59,7 @@ export function normalizeConfig(config) { // Only `files`, `relative`, `extract`, and `transform` can exist in `config.content` if ( Object.keys(config.content).some( - (key) => !['files', 'extract', 'transform', 'relative'].includes(key) + (key) => !['files', 'relative', 'extract', 'transform'].includes(key) ) ) { return false diff --git a/src/util/resolveConfigPath.js b/src/util/resolveConfigPath.js index 24fda7b5e..646a46fd4 100644 --- a/src/util/resolveConfigPath.js +++ b/src/util/resolveConfigPath.js @@ -1,6 +1,5 @@ import fs from 'fs' import path from 'path' -import { flagEnabled } from '../featureFlags.js' function isObject(value) { return typeof value === 'object' && value !== null @@ -14,36 +13,7 @@ function isString(value) { return typeof value === 'string' || value instanceof String } -/** - * This will take a possibly-relative path to a config and resolve - * it relative to the input path IF that config file exists and - * has the `resolveConfigRelativeToInput` flag enabled. - * - * If that file does not exist, or the flag is disabled, it will - * resolve the path relative to the current working directory. - * - * @param {string|undefined} inputPath - * @param {string} configPath - * @returns {string} - */ -function pickResolvedPath(configPath, inputPath) { - if (path.isAbsolute(configPath)) { - return configPath - } - - if (inputPath) { - try { - // Use require.resolve so we can find config file in parent directories - return require.resolve(configPath, { - paths: [path.dirname(inputPath)], - }) - } catch {} - } - - return path.resolve(configPath) -} - -export default function resolveConfigPath(pathOrConfig, inputPath) { +export default function resolveConfigPath(pathOrConfig) { // require('tailwindcss')({ theme: ..., variants: ... }) if (isObject(pathOrConfig) && pathOrConfig.config === undefined && !isEmpty(pathOrConfig)) { return null @@ -55,10 +25,6 @@ export default function resolveConfigPath(pathOrConfig, inputPath) { pathOrConfig.config !== undefined && isString(pathOrConfig.config) ) { - // if (pathOrConfig.experimental?.contextualPaths) { - // return pickResolvedPath(pathOrConfig.config, inputPath) - // } - return path.resolve(pathOrConfig.config) } From b2b4358dfe3c52f7a4e11105e6a707ff0098665e Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 22 Sep 2022 11:27:53 -0400 Subject: [PATCH 13/13] Split CLI into separate files --- src/cli.js | 813 +---------------------------------------------- src/cli/build.js | 644 +++++++++++++++++++++++++++++++++++++ src/cli/deps.js | 40 +++ src/cli/help.js | 70 ++++ src/cli/init.js | 50 +++ 5 files changed, 808 insertions(+), 809 deletions(-) create mode 100644 src/cli/build.js create mode 100644 src/cli/deps.js create mode 100644 src/cli/help.js create mode 100644 src/cli/init.js diff --git a/src/cli.js b/src/cli.js index 923be25c1..8ce2d2afa 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,29 +1,12 @@ #!/usr/bin/env node -import { lazyPostcss, lazyPostcssImport, lazyCssnano, lazyAutoprefixer } from '../peers/index.js' - -import chokidar from 'chokidar' import path from 'path' import arg from 'arg' import fs from 'fs' -import postcssrc from 'postcss-load-config' -import { lilconfig } from 'lilconfig' -import loadPlugins from 'postcss-load-config/src/plugins' // Little bit scary, looking at private/internal API -import loadOptions from 'postcss-load-config/src/options' // Little bit scary, looking at private/internal API -import tailwind from './processTailwindFeatures' -import resolveConfigInternal from '../resolveConfig' -import fastGlob from 'fast-glob' -import getModuleDependencies from './lib/getModuleDependencies' -import log from './util/log' -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', -} +import { help } from './cli/help.js' +import { init } from './cli/init.js' +import { build } from './cli/build.js' function isESM() { const pkgPath = path.resolve('./package.json') @@ -48,112 +31,6 @@ let configs = isESM() // --- -function indentRecursive(node, indent = 0) { - node.each && - node.each((child, i) => { - if (!child.raws.before || !child.raws.before.trim() || child.raws.before.includes('\n')) { - child.raws.before = `\n${node.type !== 'rule' && i > 0 ? '\n' : ''}${' '.repeat(indent)}` - } - child.raws.after = `\n${' '.repeat(indent)}` - indentRecursive(child, indent + 1) - }) -} - -function formatNodes(root) { - indentRecursive(root) - if (root.first) { - root.first.raws.before = '' - } -} - -async function outputFile(file, contents) { - if (fs.existsSync(file) && (await fs.promises.readFile(file, 'utf8')) === contents) { - return // Skip writing the file - } - - // Write the file - await fs.promises.writeFile(file, contents, 'utf8') -} - -function drainStdin() { - return new Promise((resolve, reject) => { - let result = '' - process.stdin.on('data', (chunk) => { - result += chunk - }) - process.stdin.on('end', () => resolve(result)) - process.stdin.on('error', (err) => reject(err)) - }) -} - -function help({ message, usage, commands, options }) { - let indent = 2 - - // Render header - console.log() - console.log(`${packageJson.name} v${packageJson.version}`) - - // Render message - if (message) { - console.log() - for (let msg of message.split('\n')) { - console.log(msg) - } - } - - // Render usage - if (usage && usage.length > 0) { - console.log() - console.log('Usage:') - for (let example of usage) { - console.log(' '.repeat(indent), example) - } - } - - // Render commands - if (commands && commands.length > 0) { - console.log() - console.log('Commands:') - for (let command of commands) { - console.log(' '.repeat(indent), command) - } - } - - // Render options - if (options) { - let groupedOptions = {} - for (let [key, value] of Object.entries(options)) { - if (typeof value === 'object') { - groupedOptions[key] = { ...value, flags: [key] } - } else { - groupedOptions[value].flags.push(key) - } - } - - console.log() - console.log('Options:') - for (let { flags, description, deprecated } of Object.values(groupedOptions)) { - if (deprecated) continue - - if (flags.length === 1) { - console.log( - ' '.repeat(indent + 4 /* 4 = "-i, ".length */), - flags.slice().reverse().join(', ').padEnd(20, ' '), - description - ) - } else { - console.log( - ' '.repeat(indent), - flags.slice().reverse().join(', ').padEnd(24, ' '), - description - ) - } - } - } - - console.log() -} - function oneOf(...options) { return Object.assign( (value = true) => { @@ -170,15 +47,6 @@ function oneOf(...options) { ) } -function loadPostcss() { - // Try to load a local `postcss` version first - try { - return require('postcss') - } catch {} - - return lazyPostcss() -} - let commands = { init: { run: init, @@ -352,677 +220,4 @@ if (args['--help']) { process.exit(0) } -run() - -// --- - -function init() { - let messages = [] - - let tailwindConfigLocation = path.resolve(args['_'][1] ?? `./${configs.tailwind}`) - if (fs.existsSync(tailwindConfigLocation)) { - messages.push(`${path.basename(tailwindConfigLocation)} already exists.`) - } else { - let stubFile = fs.readFileSync( - args['--full'] - ? path.resolve(__dirname, '../stubs/defaultConfig.stub.js') - : path.resolve(__dirname, '../stubs/simpleConfig.stub.js'), - 'utf8' - ) - - // Change colors import - stubFile = stubFile.replace('../colors', 'tailwindcss/colors') - - fs.writeFileSync(tailwindConfigLocation, stubFile, 'utf8') - - messages.push(`Created Tailwind CSS config file: ${path.basename(tailwindConfigLocation)}`) - } - - if (args['--postcss']) { - let postcssConfigLocation = path.resolve(`./${configs.postcss}`) - if (fs.existsSync(postcssConfigLocation)) { - messages.push(`${path.basename(postcssConfigLocation)} already exists.`) - } else { - let stubFile = fs.readFileSync( - path.resolve(__dirname, '../stubs/defaultPostCssConfig.stub.js'), - 'utf8' - ) - - fs.writeFileSync(postcssConfigLocation, stubFile, 'utf8') - - messages.push(`Created PostCSS config file: ${path.basename(postcssConfigLocation)}`) - } - } - - if (messages.length > 0) { - console.log() - for (let message of messages) { - console.log(message) - } - } -} - -async function build() { - let input = args['--input'] - let output = args['--output'] - let shouldWatch = args['--watch'] - let shouldPoll = args['--poll'] - let shouldCoalesceWriteEvents = shouldPoll || process.platform === 'win32' - let includePostCss = args['--postcss'] - - // Polling interval in milliseconds - // Used only when polling or coalescing add/change events on Windows - let pollInterval = 10 - - // TODO: Deprecate this in future versions - if (!input && args['_'][1]) { - console.error('[deprecation] Running tailwindcss without -i, please provide an input file.') - input = args['--input'] = args['_'][1] - } - - if (input && input !== '-' && !fs.existsSync((input = path.resolve(input)))) { - console.error(`Specified input file ${args['--input']} does not exist.`) - process.exit(9) - } - - if (args['--config'] && !fs.existsSync((args['--config'] = path.resolve(args['--config'])))) { - console.error(`Specified config file ${args['--config']} does not exist.`) - process.exit(9) - } - - // TODO: Reference the @config path here if exists - let configPath = args['--config'] - ? args['--config'] - : ((defaultPath) => (fs.existsSync(defaultPath) ? defaultPath : null))( - path.resolve(`./${configs.tailwind}`) - ) - - async function loadPostCssPlugins() { - let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined - let config = customPostCssPath - ? await (async () => { - let file = path.resolve(customPostCssPath) - - // Implementation, see: https://unpkg.com/browse/postcss-load-config@3.1.0/src/index.js - let { config = {} } = await lilconfig('postcss').load(file) - if (typeof config === 'function') { - config = config() - } else { - config = Object.assign({}, config) - } - - if (!config.plugins) { - config.plugins = [] - } - - return { - file, - plugins: loadPlugins(config, file), - options: loadOptions(config, file), - } - })() - : await postcssrc() - - let configPlugins = config.plugins - - let configPluginTailwindIdx = configPlugins.findIndex((plugin) => { - if (typeof plugin === 'function' && plugin.name === 'tailwindcss') { - return true - } - - if (typeof plugin === 'object' && plugin !== null && plugin.postcssPlugin === 'tailwindcss') { - return true - } - - return false - }) - - let beforePlugins = - configPluginTailwindIdx === -1 ? [] : configPlugins.slice(0, configPluginTailwindIdx) - let afterPlugins = - configPluginTailwindIdx === -1 - ? configPlugins - : configPlugins.slice(configPluginTailwindIdx + 1) - - return [beforePlugins, afterPlugins, config.options] - } - - function loadBuiltinPostcssPlugins() { - let postcss = loadPostcss() - let IMPORT_COMMENT = '__TAILWIND_RESTORE_IMPORT__: ' - return [ - [ - (root) => { - root.walkAtRules('import', (rule) => { - if (rule.params.slice(1).startsWith('tailwindcss/')) { - rule.after(postcss.comment({ text: IMPORT_COMMENT + rule.params })) - rule.remove() - } - }) - }, - (() => { - try { - return require('postcss-import') - } catch {} - - return lazyPostcssImport() - })(), - (root) => { - root.walkComments((rule) => { - if (rule.text.startsWith(IMPORT_COMMENT)) { - rule.after( - postcss.atRule({ - name: 'import', - params: rule.text.replace(IMPORT_COMMENT, ''), - }) - ) - rule.remove() - } - }) - }, - ], - [], - {}, - ] - } - - function resolveConfig() { - let config = configPath ? require(configPath) : {} - - if (args['--purge']) { - log.warn('purge-flag-deprecated', [ - 'The `--purge` flag has been deprecated.', - 'Please use `--content` instead.', - ]) - if (!args['--content']) { - args['--content'] = args['--purge'] - } - } - - if (args['--content']) { - let files = args['--content'].split(/(? contentPath.pattern) - } - - function extractRawContent(config) { - return config.content.files.filter((file) => { - return typeof file === 'object' && file !== null - }) - } - - function getChangedContent(config) { - let changedContent = [] - - // Resolve globs from the content config - let globs = extractFileGlobs(config) - let files = fastGlob.sync(globs) - - for (let file of files) { - changedContent.push({ - content: fs.readFileSync(path.resolve(file), 'utf8'), - extension: path.extname(file).slice(1), - }) - } - - // Resolve raw content in the tailwind config - for (let { raw: content, extension = 'html' } of extractRawContent(config)) { - changedContent.push({ content, extension }) - } - - return changedContent - } - - async function buildOnce() { - let config = resolveConfig() - let changedContent = getChangedContent(config) - - let tailwindPlugin = () => { - return { - postcssPlugin: 'tailwindcss', - Once(root, { result }) { - tailwind(({ createContext }) => { - return () => { - return createContext(config, changedContent) - } - })(root, result) - }, - } - } - - tailwindPlugin.postcss = true - - let [beforePlugins, afterPlugins, postcssOptions] = includePostCss - ? await loadPostCssPlugins() - : loadBuiltinPostcssPlugins() - - let plugins = [ - ...beforePlugins, - tailwindPlugin, - !args['--minify'] && formatNodes, - ...afterPlugins, - !args['--no-autoprefixer'] && - (() => { - // Try to load a local `autoprefixer` version first - try { - return require('autoprefixer') - } catch {} - - return lazyAutoprefixer() - })(), - args['--minify'] && - (() => { - let options = { preset: ['default', { cssDeclarationSorter: false }] } - - // Try to load a local `cssnano` version first - try { - return require('cssnano') - } catch {} - - return lazyCssnano()(options) - })(), - ].filter(Boolean) - - let postcss = loadPostcss() - let processor = postcss(plugins) - - function processCSS(css) { - let start = process.hrtime.bigint() - return Promise.resolve() - .then(() => (output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null)) - .then(() => processor.process(css, { ...postcssOptions, from: input, to: output })) - .then((result) => { - if (!output) { - return process.stdout.write(result.css) - } - - return Promise.all( - [ - outputFile(output, result.css), - result.map && outputFile(output + '.map', result.map.toString()), - ].filter(Boolean) - ) - }) - .then(() => { - let end = process.hrtime.bigint() - console.error() - console.error('Done in', (end - start) / BigInt(1e6) + 'ms.') - }) - } - - let css = await (() => { - // Piping in data, let's drain the stdin - if (input === '-') { - return drainStdin() - } - - // Input file has been provided - if (input) { - return fs.readFileSync(path.resolve(input), 'utf8') - } - - // No input file provided, fallback to default atrules - return '@tailwind base; @tailwind components; @tailwind utilities' - })() - - return processCSS(css) - } - - let context = null - - async function startWatcher() { - let changedContent = [] - let configDependencies = [] - let contextDependencies = new Set() - let watcher = null - - function refreshConfig() { - env.DEBUG && console.time('Module dependencies') - for (let file of configDependencies) { - delete require.cache[require.resolve(file)] - } - - if (configPath) { - configDependencies = getModuleDependencies(configPath).map(({ file }) => file) - - for (let dependency of configDependencies) { - contextDependencies.add(dependency) - } - } - env.DEBUG && console.timeEnd('Module dependencies') - - return resolveConfig() - } - - let [beforePlugins, afterPlugins] = includePostCss - ? await loadPostCssPlugins() - : loadBuiltinPostcssPlugins() - - let plugins = [ - ...beforePlugins, - '__TAILWIND_PLUGIN_POSITION__', - !args['--minify'] && formatNodes, - ...afterPlugins, - !args['--no-autoprefixer'] && - (() => { - // Try to load a local `autoprefixer` version first - try { - return require('autoprefixer') - } catch {} - - return lazyAutoprefixer() - })(), - args['--minify'] && - (() => { - let options = { preset: ['default', { cssDeclarationSorter: false }] } - - // Try to load a local `cssnano` version first - try { - return require('cssnano') - } catch {} - - return lazyCssnano()(options) - })(), - ].filter(Boolean) - - async function rebuild(config) { - env.DEBUG && console.time('Finished in') - - let tailwindPlugin = () => { - return { - postcssPlugin: 'tailwindcss', - Once(root, { result }) { - env.DEBUG && console.time('Compiling CSS') - tailwind(({ createContext }) => { - console.error() - console.error('Rebuilding...') - - return () => { - if (context !== null) { - context.changedContent = changedContent.splice(0) - return context - } - - env.DEBUG && console.time('Creating context') - context = createContext(config, changedContent.splice(0)) - env.DEBUG && console.timeEnd('Creating context') - return context - } - })(root, result) - env.DEBUG && console.timeEnd('Compiling CSS') - }, - } - } - - tailwindPlugin.postcss = true - - let tailwindPluginIdx = plugins.indexOf('__TAILWIND_PLUGIN_POSITION__') - let copy = plugins.slice() - copy.splice(tailwindPluginIdx, 1, tailwindPlugin) - let postcss = loadPostcss() - let processor = postcss(copy) - - function processCSS(css) { - let start = process.hrtime.bigint() - return Promise.resolve() - .then(() => - output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null - ) - .then(() => processor.process(css, { from: input, to: output })) - .then(async (result) => { - for (let message of result.messages) { - if (message.type === 'dependency') { - contextDependencies.add(message.file) - } - } - watcher.add([...contextDependencies]) - - if (!output) { - return process.stdout.write(result.css) - } - - return Promise.all( - [ - outputFile(output, result.css), - result.map && outputFile(output + '.map', result.map.toString()), - ].filter(Boolean) - ) - }) - .then(() => { - let end = process.hrtime.bigint() - console.error('Done in', (end - start) / BigInt(1e6) + 'ms.') - }) - .catch((err) => { - if (err.name === 'CssSyntaxError') { - console.error(err.toString()) - } else { - console.error(err) - } - }) - } - - let css = await (() => { - // Piping in data, let's drain the stdin - if (input === '-') { - return drainStdin() - } - - // Input file has been provided - if (input) { - return fs.readFileSync(path.resolve(input), 'utf8') - } - - // No input file provided, fallback to default atrules - return '@tailwind base; @tailwind components; @tailwind utilities' - })() - - let result = await processCSS(css) - env.DEBUG && console.timeEnd('Finished in') - return result - } - - let config = refreshConfig(configPath) - let contentPatterns = refreshContentPatterns(config) - - /** - * @param {import('../types/config.js').RequiredConfig} config - * @return {{all: string[], dynamic: string[], static: string[]}} - **/ - function refreshContentPatterns(config) { - // TODO: This can be optimized since we're using `fastGlob.generateTasks` indirectly in extractFileGlobs - let globs = extractFileGlobs(config) - let tasks = fastGlob.generateTasks(globs, { absolute: true }) - let dynamicPatterns = tasks.filter((task) => task.dynamic).flatMap((task) => task.patterns) - let staticPatterns = tasks.filter((task) => !task.dynamic).flatMap((task) => task.patterns) - - return { - all: [...staticPatterns, ...dynamicPatterns], - dynamic: dynamicPatterns, - } - } - - if (input) { - contextDependencies.add(path.resolve(input)) - } - - watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], { - // Force checking for atomic writes in all situations - // This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked - // This only works when watching directories though - atomic: true, - - usePolling: shouldPoll, - interval: shouldPoll ? pollInterval : undefined, - ignoreInitial: true, - awaitWriteFinish: shouldCoalesceWriteEvents - ? { - stabilityThreshold: 50, - pollInterval: pollInterval, - } - : false, - }) - - let chain = Promise.resolve() - let pendingRebuilds = new Set() - - watcher.on('change', async (file) => { - if (contextDependencies.has(file)) { - env.DEBUG && console.time('Resolve config') - context = null - config = refreshConfig(configPath) - contentPatterns = refreshContentPatterns(config) - env.DEBUG && console.timeEnd('Resolve config') - - env.DEBUG && console.time('Watch new files') - let globs = extractFileGlobs(config) - watcher.add(configDependencies) - watcher.add(globs) - env.DEBUG && console.timeEnd('Watch new files') - - chain = chain.then(async () => { - changedContent.push(...getChangedContent(config)) - await rebuild(config) - }) - } else { - chain = chain.then(async () => { - changedContent.push({ - content: fs.readFileSync(path.resolve(file), 'utf8'), - extension: path.extname(file).slice(1), - }) - - await rebuild(config) - }) - } - }) - - /** - * When rapidly saving files atomically a couple of situations can happen: - * - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save. - * - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held. - * - * To work around this we retry reading the file a handful of times with a delay between each attempt - * - * @param {string} path - * @param {number} tries - * @returns {string} - * @throws {Error} If the file is still missing or busy after the specified number of tries - */ - async function readFileWithRetries(path, tries = 5) { - for (let n = 0; n <= tries; n++) { - try { - return await fs.promises.readFile(path, 'utf8') - } catch (err) { - if (n !== tries) { - if (err.code === 'ENOENT' || err.code === 'EBUSY') { - await new Promise((resolve) => setTimeout(resolve, 10)) - - continue - } - } - - throw err - } - } - } - - // Restore watching any files that are "removed" - // This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed) - // TODO: An an optimization we should allow removal when the config changes - watcher.on('unlink', (file) => { - file = normalizePath(file) - - // Only re-add the file if it's not covered by a dynamic pattern - if (!micromatch.some([file], contentPatterns.dynamic)) { - watcher.add(file) - } - }) - - // Some applications such as Visual Studio (but not VS Code) - // will only fire a rename event for atomic writes and not a change event - // This is very likely a chokidar bug but it's one we need to work around - // We treat this as a change event and rebuild the CSS - watcher.on('raw', (evt, filePath, meta) => { - if (evt !== 'rename') { - return - } - - let watchedPath = meta.watchedPath - - // Watched path might be the file itself - // Or the directory it is in - filePath = watchedPath.endsWith(filePath) ? watchedPath : path.join(watchedPath, filePath) - - // Skip this event since the files it is for does not match any of the registered content globs - if (!micromatch.some([filePath], contentPatterns.all)) { - return - } - - // Skip since we've already queued a rebuild for this file that hasn't happened yet - if (pendingRebuilds.has(filePath)) { - return - } - - pendingRebuilds.add(filePath) - - chain = chain.then(async () => { - let content - - try { - content = await readFileWithRetries(path.resolve(filePath)) - } finally { - pendingRebuilds.delete(filePath) - } - - changedContent.push({ - content, - extension: path.extname(filePath).slice(1), - }) - - await rebuild(config) - }) - }) - - watcher.on('add', async (file) => { - chain = chain.then(async () => { - changedContent.push({ - content: fs.readFileSync(path.resolve(file), 'utf8'), - extension: path.extname(file).slice(1), - }) - - await rebuild(config) - }) - }) - - chain = chain.then(() => { - changedContent.push(...getChangedContent(config)) - return rebuild(config) - }) - } - - if (shouldWatch) { - /* Abort the watcher if stdin is closed to avoid zombie processes */ - process.stdin.on('end', () => process.exit(0)) - process.stdin.resume() - startWatcher() - } else { - buildOnce() - } -} +run(args, configs) diff --git a/src/cli/build.js b/src/cli/build.js new file mode 100644 index 000000000..024bd59cc --- /dev/null +++ b/src/cli/build.js @@ -0,0 +1,644 @@ +// @ts-check + +import fs from 'fs' +import path from 'path' +import postcssrc from 'postcss-load-config' +import { lilconfig } from 'lilconfig' +import loadPlugins from 'postcss-load-config/src/plugins' // Little bit scary, looking at private/internal API +import loadOptions from 'postcss-load-config/src/options' // Little bit scary, looking at private/internal API +import { loadAutoprefixer, loadCssNano, loadPostcss, loadPostcssImport } from './deps' +import log from '../util/log' +import chokidar from 'chokidar' +import fastGlob from 'fast-glob' +import micromatch from 'micromatch' +import normalizePath from 'normalize-path' +import tailwind from '../processTailwindFeatures' +import resolveConfigInternal from '../../resolveConfig' +import getModuleDependencies from '../lib/getModuleDependencies' +import { validateConfig } from '../util/validateConfig.js' +import { parseCandidateFiles } from '../lib/content.js' + +let env = { + DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0', +} + +export async function build(args, configs) { + let input = args['--input'] + let output = args['--output'] + let shouldWatch = args['--watch'] + let shouldPoll = args['--poll'] + let shouldCoalesceWriteEvents = shouldPoll || process.platform === 'win32' + let includePostCss = args['--postcss'] + + // Polling interval in milliseconds + // Used only when polling or coalescing add/change events on Windows + let pollInterval = 10 + + // TODO: Deprecate this in future versions + if (!input && args['_'][1]) { + console.error('[deprecation] Running tailwindcss without -i, please provide an input file.') + input = args['--input'] = args['_'][1] + } + + if (input && input !== '-' && !fs.existsSync((input = path.resolve(input)))) { + console.error(`Specified input file ${args['--input']} does not exist.`) + process.exit(9) + } + + if (args['--config'] && !fs.existsSync((args['--config'] = path.resolve(args['--config'])))) { + console.error(`Specified config file ${args['--config']} does not exist.`) + process.exit(9) + } + + // TODO: Reference the @config path here if exists + let configPath = args['--config'] + ? args['--config'] + : ((defaultPath) => (fs.existsSync(defaultPath) ? defaultPath : null))( + path.resolve(`./${configs.tailwind}`) + ) + + async function loadPostCssPlugins() { + let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined + let config = customPostCssPath + ? await (async () => { + let file = path.resolve(customPostCssPath) + + // Implementation, see: https://unpkg.com/browse/postcss-load-config@3.1.0/src/index.js + let { config = {} } = await lilconfig('postcss').load(file) + if (typeof config === 'function') { + config = config() + } else { + config = Object.assign({}, config) + } + + if (!config.plugins) { + config.plugins = [] + } + + return { + file, + plugins: loadPlugins(config, file), + options: loadOptions(config, file), + } + })() + : await postcssrc() + + let configPlugins = config.plugins + + let configPluginTailwindIdx = configPlugins.findIndex((plugin) => { + if (typeof plugin === 'function' && plugin.name === 'tailwindcss') { + return true + } + + if (typeof plugin === 'object' && plugin !== null && plugin.postcssPlugin === 'tailwindcss') { + return true + } + + return false + }) + + let beforePlugins = + configPluginTailwindIdx === -1 ? [] : configPlugins.slice(0, configPluginTailwindIdx) + let afterPlugins = + configPluginTailwindIdx === -1 + ? configPlugins + : configPlugins.slice(configPluginTailwindIdx + 1) + + return [beforePlugins, afterPlugins, config.options] + } + + function loadBuiltinPostcssPlugins() { + let postcss = loadPostcss() + let IMPORT_COMMENT = '__TAILWIND_RESTORE_IMPORT__: ' + return [ + [ + (root) => { + root.walkAtRules('import', (rule) => { + if (rule.params.slice(1).startsWith('tailwindcss/')) { + rule.after(postcss.comment({ text: IMPORT_COMMENT + rule.params })) + rule.remove() + } + }) + }, + loadPostcssImport(), + (root) => { + root.walkComments((rule) => { + if (rule.text.startsWith(IMPORT_COMMENT)) { + rule.after( + postcss.atRule({ + name: 'import', + params: rule.text.replace(IMPORT_COMMENT, ''), + }) + ) + rule.remove() + } + }) + }, + ], + [], + {}, + ] + } + + function resolveConfig() { + let config = configPath ? require(configPath) : {} + + if (args['--purge']) { + log.warn('purge-flag-deprecated', [ + 'The `--purge` flag has been deprecated.', + 'Please use `--content` instead.', + ]) + if (!args['--content']) { + args['--content'] = args['--purge'] + } + } + + if (args['--content']) { + let files = args['--content'].split(/(? contentPath.pattern) + } + + function extractRawContent(config) { + return config.content.files.filter((file) => { + return typeof file === 'object' && file !== null + }) + } + + function getChangedContent(config) { + let changedContent = [] + + // Resolve globs from the content config + let globs = extractFileGlobs(config) + let files = fastGlob.sync(globs) + + for (let file of files) { + changedContent.push({ + content: fs.readFileSync(path.resolve(file), 'utf8'), + extension: path.extname(file).slice(1), + }) + } + + // Resolve raw content in the tailwind config + for (let { raw: content, extension = 'html' } of extractRawContent(config)) { + changedContent.push({ content, extension }) + } + + return changedContent + } + + async function buildOnce() { + let config = resolveConfig() + let changedContent = getChangedContent(config) + + let tailwindPlugin = () => { + return { + postcssPlugin: 'tailwindcss', + Once(root, { result }) { + tailwind(({ createContext }) => { + return () => { + return createContext(config, changedContent) + } + })(root, result) + }, + } + } + + tailwindPlugin.postcss = true + + let [beforePlugins, afterPlugins, postcssOptions] = includePostCss + ? await loadPostCssPlugins() + : loadBuiltinPostcssPlugins() + + let plugins = [ + ...beforePlugins, + tailwindPlugin, + !args['--minify'] && formatNodes, + ...afterPlugins, + !args['--no-autoprefixer'] && loadAutoprefixer(), + args['--minify'] && loadCssNano(), + ].filter(Boolean) + + let postcss = loadPostcss() + let processor = postcss(plugins) + + function processCSS(css) { + let start = process.hrtime.bigint() + return Promise.resolve() + .then(() => (output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null)) + .then(() => processor.process(css, { ...postcssOptions, from: input, to: output })) + .then((result) => { + if (!output) { + return process.stdout.write(result.css) + } + + return Promise.all( + [ + outputFile(output, result.css), + result.map && outputFile(output + '.map', result.map.toString()), + ].filter(Boolean) + ) + }) + .then(() => { + let end = process.hrtime.bigint() + console.error() + console.error('Done in', (end - start) / BigInt(1e6) + 'ms.') + }) + } + + let css = await (() => { + // Piping in data, let's drain the stdin + if (input === '-') { + return drainStdin() + } + + // Input file has been provided + if (input) { + return fs.readFileSync(path.resolve(input), 'utf8') + } + + // No input file provided, fallback to default atrules + return '@tailwind base; @tailwind components; @tailwind utilities' + })() + + return processCSS(css) + } + + let context = null + + async function startWatcher() { + let changedContent = [] + let configDependencies = [] + let contextDependencies = new Set() + let watcher = null + + function refreshConfig() { + env.DEBUG && console.time('Module dependencies') + for (let file of configDependencies) { + delete require.cache[require.resolve(file)] + } + + if (configPath) { + configDependencies = getModuleDependencies(configPath).map(({ file }) => file) + + for (let dependency of configDependencies) { + contextDependencies.add(dependency) + } + } + env.DEBUG && console.timeEnd('Module dependencies') + + return resolveConfig() + } + + let [beforePlugins, afterPlugins] = includePostCss + ? await loadPostCssPlugins() + : loadBuiltinPostcssPlugins() + + let plugins = [ + ...beforePlugins, + '__TAILWIND_PLUGIN_POSITION__', + !args['--minify'] && formatNodes, + ...afterPlugins, + !args['--no-autoprefixer'] && loadAutoprefixer(), + args['--minify'] && loadCssNano(), + ].filter(Boolean) + + async function rebuild(config) { + env.DEBUG && console.time('Finished in') + + let tailwindPlugin = () => { + return { + postcssPlugin: 'tailwindcss', + Once(root, { result }) { + env.DEBUG && console.time('Compiling CSS') + tailwind(({ createContext }) => { + console.error() + console.error('Rebuilding...') + + return () => { + if (context !== null) { + context.changedContent = changedContent.splice(0) + return context + } + + env.DEBUG && console.time('Creating context') + context = createContext(config, changedContent.splice(0)) + env.DEBUG && console.timeEnd('Creating context') + return context + } + })(root, result) + env.DEBUG && console.timeEnd('Compiling CSS') + }, + } + } + + tailwindPlugin.postcss = true + + let tailwindPluginIdx = plugins.indexOf('__TAILWIND_PLUGIN_POSITION__') + let copy = plugins.slice() + copy.splice(tailwindPluginIdx, 1, tailwindPlugin) + let postcss = loadPostcss() + let processor = postcss(copy) + + function processCSS(css) { + let start = process.hrtime.bigint() + return Promise.resolve() + .then(() => + output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null + ) + .then(() => processor.process(css, { from: input, to: output })) + .then(async (result) => { + for (let message of result.messages) { + if (message.type === 'dependency') { + contextDependencies.add(message.file) + } + } + watcher.add([...contextDependencies]) + + if (!output) { + return process.stdout.write(result.css) + } + + return Promise.all( + [ + outputFile(output, result.css), + result.map && outputFile(output + '.map', result.map.toString()), + ].filter(Boolean) + ) + }) + .then(() => { + let end = process.hrtime.bigint() + console.error('Done in', (end - start) / BigInt(1e6) + 'ms.') + }) + .catch((err) => { + if (err.name === 'CssSyntaxError') { + console.error(err.toString()) + } else { + console.error(err) + } + }) + } + + let css = await (() => { + // Piping in data, let's drain the stdin + if (input === '-') { + return drainStdin() + } + + // Input file has been provided + if (input) { + return fs.readFileSync(path.resolve(input), 'utf8') + } + + // No input file provided, fallback to default atrules + return '@tailwind base; @tailwind components; @tailwind utilities' + })() + + let result = await processCSS(css) + env.DEBUG && console.timeEnd('Finished in') + return result + } + + let config = refreshConfig() + let contentPatterns = refreshContentPatterns(config) + + /** + * @param {import('../../types/config.js').RequiredConfig} config + * @return {{all: string[], dynamic: string[]}} + **/ + function refreshContentPatterns(config) { + // TODO: This can be optimized since we're using `fastGlob.generateTasks` indirectly in extractFileGlobs + let globs = extractFileGlobs(config) + let tasks = fastGlob.generateTasks(globs, { absolute: true }) + let dynamicPatterns = tasks.filter((task) => task.dynamic).flatMap((task) => task.patterns) + let staticPatterns = tasks.filter((task) => !task.dynamic).flatMap((task) => task.patterns) + + return { + all: [...staticPatterns, ...dynamicPatterns], + dynamic: dynamicPatterns, + } + } + + if (input) { + contextDependencies.add(path.resolve(input)) + } + + watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], { + // Force checking for atomic writes in all situations + // This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked + // This only works when watching directories though + atomic: true, + + usePolling: shouldPoll, + interval: shouldPoll ? pollInterval : undefined, + ignoreInitial: true, + awaitWriteFinish: shouldCoalesceWriteEvents + ? { + stabilityThreshold: 50, + pollInterval: pollInterval, + } + : false, + }) + + let chain = Promise.resolve() + let pendingRebuilds = new Set() + + watcher.on('change', async (file) => { + if (contextDependencies.has(file)) { + env.DEBUG && console.time('Resolve config') + context = null + config = refreshConfig() + contentPatterns = refreshContentPatterns(config) + env.DEBUG && console.timeEnd('Resolve config') + + env.DEBUG && console.time('Watch new files') + let globs = extractFileGlobs(config) + watcher.add(configDependencies) + watcher.add(globs) + env.DEBUG && console.timeEnd('Watch new files') + + chain = chain.then(async () => { + changedContent.push(...getChangedContent(config)) + await rebuild(config) + }) + } else { + chain = chain.then(async () => { + changedContent.push({ + content: fs.readFileSync(path.resolve(file), 'utf8'), + extension: path.extname(file).slice(1), + }) + + await rebuild(config) + }) + } + }) + + /** + * When rapidly saving files atomically a couple of situations can happen: + * - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save. + * - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held. + * + * To work around this we retry reading the file a handful of times with a delay between each attempt + * + * @param {string} path + * @param {number} tries + * @returns {Promise} + * @throws {Error} If the file is still missing or busy after the specified number of tries + */ + async function readFileWithRetries(path, tries = 5) { + for (let n = 0; n <= tries; n++) { + try { + return await fs.promises.readFile(path, 'utf8') + } catch (err) { + if (n !== tries) { + if (err.code === 'ENOENT' || err.code === 'EBUSY') { + await new Promise((resolve) => setTimeout(resolve, 10)) + + continue + } + } + + throw err + } + } + } + + // Restore watching any files that are "removed" + // This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed) + // TODO: An an optimization we should allow removal when the config changes + watcher.on('unlink', (file) => { + file = normalizePath(file) + + // Only re-add the file if it's not covered by a dynamic pattern + if (!micromatch.some([file], contentPatterns.dynamic)) { + watcher.add(file) + } + }) + + // Some applications such as Visual Studio (but not VS Code) + // will only fire a rename event for atomic writes and not a change event + // This is very likely a chokidar bug but it's one we need to work around + // We treat this as a change event and rebuild the CSS + watcher.on('raw', (evt, filePath, meta) => { + if (evt !== 'rename') { + return + } + + let watchedPath = meta.watchedPath + + // Watched path might be the file itself + // Or the directory it is in + filePath = watchedPath.endsWith(filePath) ? watchedPath : path.join(watchedPath, filePath) + + // Skip this event since the files it is for does not match any of the registered content globs + if (!micromatch.some([filePath], contentPatterns.all)) { + return + } + + // Skip since we've already queued a rebuild for this file that hasn't happened yet + if (pendingRebuilds.has(filePath)) { + return + } + + pendingRebuilds.add(filePath) + + chain = chain.then(async () => { + let content + + try { + content = await readFileWithRetries(path.resolve(filePath)) + } finally { + pendingRebuilds.delete(filePath) + } + + changedContent.push({ + content, + extension: path.extname(filePath).slice(1), + }) + + await rebuild(config) + }) + }) + + watcher.on('add', async (file) => { + chain = chain.then(async () => { + changedContent.push({ + content: fs.readFileSync(path.resolve(file), 'utf8'), + extension: path.extname(file).slice(1), + }) + + await rebuild(config) + }) + }) + + chain = chain.then(() => { + changedContent.push(...getChangedContent(config)) + return rebuild(config) + }) + } + + if (shouldWatch) { + /* Abort the watcher if stdin is closed to avoid zombie processes */ + process.stdin.on('end', () => process.exit(0)) + process.stdin.resume() + startWatcher() + } else { + buildOnce() + } +} + +function drainStdin() { + return new Promise((resolve, reject) => { + let result = '' + process.stdin.on('data', (chunk) => { + result += chunk + }) + process.stdin.on('end', () => resolve(result)) + process.stdin.on('error', (err) => reject(err)) + }) +} + +function indentRecursive(node, indent = 0) { + node.each && + node.each((child, i) => { + if (!child.raws.before || !child.raws.before.trim() || child.raws.before.includes('\n')) { + child.raws.before = `\n${node.type !== 'rule' && i > 0 ? '\n' : ''}${' '.repeat(indent)}` + } + child.raws.after = `\n${' '.repeat(indent)}` + indentRecursive(child, indent + 1) + }) +} + +function formatNodes(root) { + indentRecursive(root) + if (root.first) { + root.first.raws.before = '' + } +} + +async function outputFile(file, contents) { + if (fs.existsSync(file) && (await fs.promises.readFile(file, 'utf8')) === contents) { + return // Skip writing the file + } + + // Write the file + await fs.promises.writeFile(file, contents, 'utf8') +} diff --git a/src/cli/deps.js b/src/cli/deps.js new file mode 100644 index 000000000..7a897a292 --- /dev/null +++ b/src/cli/deps.js @@ -0,0 +1,40 @@ +// @ts-check + +import { lazyPostcss, lazyPostcssImport, lazyCssnano, lazyAutoprefixer } from '../../peers/index.js' + +export function loadPostcss() { + // Try to load a local `postcss` version first + try { + return require('postcss') + } catch {} + + return lazyPostcss() +} + +export function loadPostcssImport() { + try { + return require('postcss-import') + } catch {} + + return lazyPostcssImport() +} + +export function loadCssNano() { + let options = { preset: ['default', { cssDeclarationSorter: false }] } + + // Try to load a local `cssnano` version first + try { + return require('cssnano') + } catch {} + + return lazyCssnano()(options) +} + +export function loadAutoprefixer() { + // Try to load a local `autoprefixer` version first + try { + return require('autoprefixer') + } catch {} + + return lazyAutoprefixer() +} diff --git a/src/cli/help.js b/src/cli/help.js new file mode 100644 index 000000000..86b741e35 --- /dev/null +++ b/src/cli/help.js @@ -0,0 +1,70 @@ +// @ts-check +import packageJson from '../../package.json' + +export function help({ message, usage, commands, options }) { + let indent = 2 + + // Render header + console.log() + console.log(`${packageJson.name} v${packageJson.version}`) + + // Render message + if (message) { + console.log() + for (let msg of message.split('\n')) { + console.log(msg) + } + } + + // Render usage + if (usage && usage.length > 0) { + console.log() + console.log('Usage:') + for (let example of usage) { + console.log(' '.repeat(indent), example) + } + } + + // Render commands + if (commands && commands.length > 0) { + console.log() + console.log('Commands:') + for (let command of commands) { + console.log(' '.repeat(indent), command) + } + } + + // Render options + if (options) { + let groupedOptions = {} + for (let [key, value] of Object.entries(options)) { + if (typeof value === 'object') { + groupedOptions[key] = { ...value, flags: [key] } + } else { + groupedOptions[value].flags.push(key) + } + } + + console.log() + console.log('Options:') + for (let { flags, description, deprecated } of Object.values(groupedOptions)) { + if (deprecated) continue + + if (flags.length === 1) { + console.log( + ' '.repeat(indent + 4 /* 4 = "-i, ".length */), + flags.slice().reverse().join(', ').padEnd(20, ' '), + description + ) + } else { + console.log( + ' '.repeat(indent), + flags.slice().reverse().join(', ').padEnd(24, ' '), + description + ) + } + } + } + + console.log() +} diff --git a/src/cli/init.js b/src/cli/init.js new file mode 100644 index 000000000..dde27ea6c --- /dev/null +++ b/src/cli/init.js @@ -0,0 +1,50 @@ +// @ts-check + +import fs from 'fs' +import path from 'path' + +export function init(args, configs) { + let messages = [] + + let tailwindConfigLocation = path.resolve(args['_'][1] ?? `./${configs.tailwind}`) + if (fs.existsSync(tailwindConfigLocation)) { + messages.push(`${path.basename(tailwindConfigLocation)} already exists.`) + } else { + let stubFile = fs.readFileSync( + args['--full'] + ? path.resolve(__dirname, '../stubs/defaultConfig.stub.js') + : path.resolve(__dirname, '../stubs/simpleConfig.stub.js'), + 'utf8' + ) + + // Change colors import + stubFile = stubFile.replace('../colors', 'tailwindcss/colors') + + fs.writeFileSync(tailwindConfigLocation, stubFile, 'utf8') + + messages.push(`Created Tailwind CSS config file: ${path.basename(tailwindConfigLocation)}`) + } + + if (args['--postcss']) { + let postcssConfigLocation = path.resolve(`./${configs.postcss}`) + if (fs.existsSync(postcssConfigLocation)) { + messages.push(`${path.basename(postcssConfigLocation)} already exists.`) + } else { + let stubFile = fs.readFileSync( + path.resolve(__dirname, '../stubs/defaultPostCssConfig.stub.js'), + 'utf8' + ) + + fs.writeFileSync(postcssConfigLocation, stubFile, 'utf8') + + messages.push(`Created PostCSS config file: ${path.basename(postcssConfigLocation)}`) + } + } + + if (messages.length > 0) { + console.log() + for (let message of messages) { + console.log(message) + } + } +}