diff --git a/CHANGELOG.md b/CHANGELOG.md index 1164f6f1bce4..2c7a09b85918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow negating utilities using min/max/clamp ([#9237](https://github.com/tailwindlabs/tailwindcss/pull/9237)) - Add new `collapse` utility for `visibility: collapse` ([#9181](https://github.com/tailwindlabs/tailwindcss/pull/9181)) - Allow resolving content paths relative to the config file ([#9396](https://github.com/tailwindlabs/tailwindcss/pull/9396)) +- Add `@config` support ([#9405](https://github.com/tailwindlabs/tailwindcss/pull/9405)) ### Fixed diff --git a/integrations/tailwindcss-cli/tests/integration.test.js b/integrations/tailwindcss-cli/tests/integration.test.js index 8a428f83120d..fbfdc6f0db96 100644 --- a/integrations/tailwindcss-cli/tests/integration.test.js +++ b/integrations/tailwindcss-cli/tests/integration.test.js @@ -1,3 +1,4 @@ +let fs = require('fs') let $ = require('../../execute') let { css, html, javascript } = require('../../syntax') @@ -88,6 +89,108 @@ describe('static build', () => { ` ) }) + + it('can read from a config file from an @config directive', async () => { + await writeInputFile('index.html', html`
`) + await writeInputFile( + 'index.css', + css` + @config "./tailwind.config.js"; + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + ) + await writeInputFile( + 'tailwind.config.js', + javascript` + module.exports = { + content: { + relative: true, + files: ['./index.html'], + }, + theme: { + extend: { + colors: { + yellow: '#ff0', + } + }, + }, + corePlugins: { + preflight: false, + }, + } + ` + ) + + await $('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css', { + env: { NODE_ENV: 'production' }, + }) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .bg-yellow { + --tw-bg-opacity: 1; + background-color: rgb(255 255 0 / var(--tw-bg-opacity)); + } + ` + ) + }) + + it('can read from a config file from an @config directive inside an @import from postcss-import', async () => { + await fs.promises.mkdir('./src/config', { recursive: true }) + + await writeInputFile('index.html', html`
`) + await writeInputFile( + 'config/myconfig.css', + css` + @config "../tailwind.config.js"; + ` + ) + await writeInputFile( + 'index.css', + css` + @import './config/myconfig'; + @import 'tailwindcss/base'; + @import 'tailwindcss/components'; + @import 'tailwindcss/utilities'; + ` + ) + await writeInputFile( + 'tailwind.config.js', + javascript` + module.exports = { + content: { + relative: true, + files: ['./index.html'], + }, + theme: { + extend: { + colors: { + yellow: '#ff0', + } + }, + }, + corePlugins: { + preflight: false, + }, + } + ` + ) + + await $('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css', { + env: { NODE_ENV: 'production' }, + }) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .bg-yellow { + --tw-bg-opacity: 1; + background-color: rgb(255 255 0 / var(--tw-bg-opacity)); + } + ` + ) + }) }) describe('watcher', () => { @@ -381,4 +484,125 @@ describe('watcher', () => { return runningProcess.stop() }) + + test('listens for changes to the @config directive', async () => { + await writeInputFile('index.html', html`
`) + await writeInputFile( + 'index.css', + css` + @config "./tailwind.config.js"; + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + ) + await writeInputFile( + 'tailwind.config.js', + javascript` + module.exports = { + content: { + relative: true, + files: ['./index.html'], + }, + theme: { + extend: { + colors: { + yellow: '#ff0', + } + }, + }, + corePlugins: { + preflight: false, + }, + } + ` + ) + await writeInputFile( + 'tailwind.2.config.js', + javascript` + module.exports = { + content: { + relative: true, + files: ['./index.html'], + }, + theme: { + extend: { + colors: { + yellow: '#ff7', + } + }, + }, + corePlugins: { + preflight: false, + }, + } + ` + ) + + let runningProcess = $('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w') + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .bg-yellow { + --tw-bg-opacity: 1; + background-color: rgb(255 255 0 / var(--tw-bg-opacity)); + } + ` + ) + + await writeInputFile( + 'index.css', + css` + @config "./tailwind.2.config.js"; + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .bg-yellow { + --tw-bg-opacity: 1; + background-color: rgb(255 255 119 / var(--tw-bg-opacity)); + } + ` + ) + + await writeInputFile( + 'tailwind.2.config.js', + javascript` + module.exports = { + content: { + relative: true, + files: ['./index.html'], + }, + theme: { + extend: { + colors: { + yellow: '#fff', + } + }, + }, + corePlugins: { + preflight: false, + }, + } + ` + ) + await runningProcess.onStderr(ready) + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .bg-yellow { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + } + ` + ) + + return runningProcess.stop() + }) }) diff --git a/src/cli.js b/src/cli.js index 54d03743cabf..b2199e00c707 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 { build } from './cli/build' +import { help } from './cli/help' +import { init } from './cli/init' 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,675 +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) - } - - 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) { - 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/deps.js b/src/cli/build/deps.js new file mode 100644 index 000000000000..9435b929a2bc --- /dev/null +++ b/src/cli/build/deps.js @@ -0,0 +1,56 @@ +// @ts-check + +import { + // @ts-ignore + lazyPostcss, + + // @ts-ignore + lazyPostcssImport, + + // @ts-ignore + lazyCssnano, + + // @ts-ignore + lazyAutoprefixer, +} from '../../../peers/index.js' + +/** + * @returns {import('postcss')} + */ +export function loadPostcss() { + // Try to load a local `postcss` version first + try { + return require('postcss') + } catch {} + + return lazyPostcss() +} + +export function loadPostcssImport() { + // Try to load a local `postcss-import` version first + 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/build/index.js b/src/cli/build/index.js new file mode 100644 index 000000000000..763b3d56b75a --- /dev/null +++ b/src/cli/build/index.js @@ -0,0 +1,45 @@ +// @ts-check + +import fs from 'fs' +import path from 'path' +import { createProcessor } from './plugin.js' + +export async function build(args, configs) { + let input = args['--input'] + let shouldWatch = args['--watch'] + + // 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}`) + ) + + let processor = await createProcessor(args, configPath) + + if (shouldWatch) { + /* Abort the watcher if stdin is closed to avoid zombie processes */ + process.stdin.on('end', () => process.exit(0)) + process.stdin.resume() + + await processor.watch() + } else { + await processor.build() + } +} diff --git a/src/cli/build/plugin.js b/src/cli/build/plugin.js new file mode 100644 index 000000000000..ed07eea697e7 --- /dev/null +++ b/src/cli/build/plugin.js @@ -0,0 +1,372 @@ +// @ts-check + +import path from 'path' +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 { loadAutoprefixer, loadCssNano, loadPostcss, loadPostcssImport } from './deps' +import { formatNodes, drainStdin, outputFile } from './utils' +import { env } from '../shared' +import resolveConfig from '../../../resolveConfig.js' +import getModuleDependencies from '../../lib/getModuleDependencies.js' +import { parseCandidateFiles } from '../../lib/content.js' +import { createWatcher } from './watching.js' +import fastGlob from 'fast-glob' +import { findAtConfigPath } from '../../lib/findAtConfigPath.js' + +/** + * + * @param {string} [customPostCssPath ] + * @returns + */ +async function loadPostCssPlugins(customPostCssPath) { + 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 + // @ts-ignore + 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() + } + }) + }, + ], + [], + {}, + ] +} + +let state = { + /** @type {any} */ + context: null, + + /** @type {ReturnType | null} */ + watcher: null, + + /** @type {{content: string, extension: string}[]} */ + changedContent: [], + + configDependencies: new Set(), + contextDependencies: new Set(), + + /** @type {import('../../lib/content.js').ContentPath[]} */ + contentPaths: [], + + refreshContentPaths() { + this.contentPaths = parseCandidateFiles(this.context, this.context?.tailwindConfig) + }, + + get config() { + return this.context.tailwindConfig + }, + + get contentPatterns() { + return { + all: this.contentPaths.map((contentPath) => contentPath.pattern), + dynamic: this.contentPaths + .filter((contentPath) => contentPath.glob !== undefined) + .map((contentPath) => contentPath.pattern), + } + }, + + loadConfig(configPath) { + if (this.watcher && configPath) { + this.refreshConfigDependencies(configPath) + } + + let config = configPath ? require(configPath) : {} + + // @ts-ignore + config = resolveConfig(config, { content: { files: [] } }) + + return config + }, + + refreshConfigDependencies(configPath) { + env.DEBUG && console.time('Module dependencies') + + for (let file of this.configDependencies) { + delete require.cache[require.resolve(file)] + } + + if (configPath) { + let deps = getModuleDependencies(configPath).map(({ file }) => file) + + for (let dependency of deps) { + this.configDependencies.add(dependency) + } + } + + env.DEBUG && console.timeEnd('Module dependencies') + }, + + readContentPaths() { + let content = [] + + // Resolve globs from the content config + // TODO: When we make the postcss plugin async-capable this can become async + let files = fastGlob.sync(this.contentPatterns.all) + + for (let file of files) { + content.push({ + content: fs.readFileSync(path.resolve(file), 'utf8'), + extension: path.extname(file).slice(1), + }) + } + + // Resolve raw content in the tailwind config + let rawContent = this.config.content.files.filter((file) => { + return file !== null && typeof file === 'object' + }) + + for (let { raw: content, extension = 'html' } of rawContent) { + content.push({ content, extension }) + } + + return content + }, + + getContext({ createContext, cliConfigPath, root, result }) { + if (this.context) { + this.context.changedContent = this.changedContent.splice(0) + + return this.context + } + + env.DEBUG && console.time('Searching for config') + let configPath = findAtConfigPath(root, result) ?? cliConfigPath + env.DEBUG && console.timeEnd('Searching for config') + + env.DEBUG && console.time('Loading config') + let config = this.loadConfig(configPath) + env.DEBUG && console.timeEnd('Loading config') + + env.DEBUG && console.time('Creating context') + this.context = createContext(config, []) + Object.assign(this.context, { + userConfigPath: configPath, + }) + env.DEBUG && console.timeEnd('Creating context') + + env.DEBUG && console.time('Resolving content paths') + this.refreshContentPaths() + env.DEBUG && console.timeEnd('Resolving content paths') + + if (this.watcher) { + env.DEBUG && console.time('Watch new files') + this.watcher.refreshWatchedFiles() + env.DEBUG && console.timeEnd('Watch new files') + } + + env.DEBUG && console.time('Reading content files') + for (let file of this.readContentPaths()) { + this.context.changedContent.push(file) + } + env.DEBUG && console.timeEnd('Reading content files') + + return this.context + }, +} + +export async function createProcessor(args, cliConfigPath) { + let postcss = loadPostcss() + + let input = args['--input'] + let output = args['--output'] + let includePostCss = args['--postcss'] + let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined + + let [beforePlugins, afterPlugins, postcssOptions] = includePostCss + ? await loadPostCssPlugins(customPostCssPath) + : loadBuiltinPostcssPlugins() + + let tailwindPlugin = () => { + return { + postcssPlugin: 'tailwindcss', + Once(root, { result }) { + env.DEBUG && console.time('Compiling CSS') + tailwind(({ createContext }) => { + console.error() + console.error('Rebuilding...') + + return () => { + return state.getContext({ createContext, cliConfigPath, root, result }) + } + })(root, result) + env.DEBUG && console.timeEnd('Compiling CSS') + }, + } + } + + tailwindPlugin.postcss = true + + let plugins = [ + ...beforePlugins, + tailwindPlugin, + !args['--minify'] && formatNodes, + ...afterPlugins, + !args['--no-autoprefixer'] && loadAutoprefixer(), + args['--minify'] && loadCssNano(), + ].filter(Boolean) + + /** @type {import('postcss').Processor} */ + // @ts-ignore + let processor = postcss(plugins) + + async function readInput() { + // Piping in data, let's drain the stdin + if (input === '-') { + return drainStdin() + } + + // Input file has been provided + if (input) { + return fs.promises.readFile(path.resolve(input), 'utf8') + } + + // No input file provided, fallback to default atrules + return '@tailwind base; @tailwind components; @tailwind utilities' + } + + async function build() { + let start = process.hrtime.bigint() + + return readInput() + .then((css) => processor.process(css, { ...postcssOptions, from: input, to: output })) + .then((result) => { + if (!output) { + process.stdout.write(result.css) + return + } + + return Promise.all([ + outputFile(output, result.css), + result.map && outputFile(output + '.map', result.map.toString()), + ]) + }) + .then(() => { + let end = process.hrtime.bigint() + console.error() + console.error('Done in', (end - start) / BigInt(1e6) + 'ms.') + }) + } + + /** + * @param {{file: string, content(): Promise, extension: string}[]} changes + */ + async function parseChanges(changes) { + return Promise.all( + changes.map(async (change) => ({ + content: await change.content(), + extension: change.extension, + })) + ) + } + + if (input !== undefined && input !== '-') { + state.contextDependencies.add(path.resolve(input)) + } + + return { + build, + watch: async () => { + state.watcher = createWatcher(args, { + state, + + /** + * @param {{file: string, content(): Promise, extension: string}[]} changes + */ + async rebuild(changes) { + let needsNewContext = changes.some((change) => { + return ( + state.configDependencies.has(change.file) || + state.contextDependencies.has(change.file) + ) + }) + + if (needsNewContext) { + state.context = null + } else { + for (let change of await parseChanges(changes)) { + state.changedContent.push(change) + } + } + + return build() + }, + }) + + await build() + }, + } +} diff --git a/src/cli/build/utils.js b/src/cli/build/utils.js new file mode 100644 index 000000000000..3462a97935bb --- /dev/null +++ b/src/cli/build/utils.js @@ -0,0 +1,76 @@ +// @ts-check + +import fs from 'fs' +import path from 'path' + +export 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) + }) +} + +export function formatNodes(root) { + indentRecursive(root) + if (root.first) { + root.first.raws.before = '' + } +} + +/** + * 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 + */ +export 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 + } + } +} + +export 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)) + }) +} + +export async function outputFile(file, newContents) { + try { + let currentContents = await fs.promises.readFile(file, 'utf8') + if (currentContents === newContents) { + return // Skip writing the file + } + } catch {} + + // Write the file + await fs.promises.mkdir(path.dirname(file), { recursive: true }) + await fs.promises.writeFile(file, newContents, 'utf8') +} diff --git a/src/cli/build/watching.js b/src/cli/build/watching.js new file mode 100644 index 000000000000..8ebceccf5cc7 --- /dev/null +++ b/src/cli/build/watching.js @@ -0,0 +1,134 @@ +// @ts-check + +import chokidar from 'chokidar' +import fs from 'fs' +import micromatch from 'micromatch' +import normalizePath from 'normalize-path' +import path from 'path' + +import { readFileWithRetries } from './utils.js' + +/** + * + * @param {*} args + * @param {{ state, rebuild(changedFiles: any[]): Promise }} param1 + * @returns {{ + * fswatcher: import('chokidar').FSWatcher, + * refreshWatchedFiles(): void, + * }} + */ +export function createWatcher(args, { state, rebuild }) { + let shouldPoll = args['--poll'] + let shouldCoalesceWriteEvents = shouldPoll || process.platform === 'win32' + + // Polling interval in milliseconds + // Used only when polling or coalescing add/change events on Windows + let pollInterval = 10 + + let watcher = chokidar.watch([], { + // 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() + let changedContent = [] + + /** + * + * @param {*} file + * @param {(() => Promise) | null} content + */ + function recordChangedFile(file, content = null) { + file = path.resolve(file) + + content = content ?? (async () => await fs.promises.readFile(file, 'utf8')) + + changedContent.push({ + file, + content, + extension: path.extname(file).slice(1), + }) + + chain = chain.then(() => rebuild(changedContent)) + + return chain + } + + watcher.on('change', (file) => recordChangedFile(file)) + watcher.on('add', (file) => recordChangedFile(file)) + + // 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], state.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], state.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) + } + + return recordChangedFile(filePath, () => content) + }) + }) + + return { + fswatcher: watcher, + + refreshWatchedFiles() { + watcher.add(Array.from(state.contextDependencies)) + watcher.add(Array.from(state.configDependencies)) + watcher.add(state.contentPatterns.all) + }, + } +} diff --git a/src/cli/help/index.js b/src/cli/help/index.js new file mode 100644 index 000000000000..ea4137a10d4c --- /dev/null +++ b/src/cli/help/index.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/index.js b/src/cli/index.js new file mode 100644 index 000000000000..61f1de91d974 --- /dev/null +++ b/src/cli/index.js @@ -0,0 +1,3 @@ +export * from './build' +export * from './config' +export * from './content' diff --git a/src/cli/init/index.js b/src/cli/init/index.js new file mode 100644 index 000000000000..470680318fa6 --- /dev/null +++ b/src/cli/init/index.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) + } + } +} diff --git a/src/cli/shared.js b/src/cli/shared.js new file mode 100644 index 000000000000..56c85fda4780 --- /dev/null +++ b/src/cli/shared.js @@ -0,0 +1,5 @@ +// @ts-check + +export const env = { + DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0', +} diff --git a/src/index.js b/src/index.js index 9e9e291eb797..7dc41c3170fd 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/findAtConfigPath.js b/src/lib/findAtConfigPath.js new file mode 100644 index 000000000000..179d02cabb28 --- /dev/null +++ b/src/lib/findAtConfigPath.js @@ -0,0 +1,50 @@ +import fs from 'fs' +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 = null + + root.walkAtRules('config', (rule) => { + relativeTo = rule.source?.input.file ?? result.opts.from ?? null + + if (relativeTo === null) { + 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.') + } + + 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.' + ) + } + + let inputPath = matches[2] + if (path.isAbsolute(inputPath)) { + throw rule.error('The `@config` at-rule cannot be used with an absolute path.') + } + + configPath = path.resolve(path.dirname(relativeTo), inputPath) + if (!fs.existsSync(configPath)) { + throw rule.error( + `The config file at "${inputPath}" does not exist. Make sure the path is correct and the file exists.` + ) + } + + rule.remove() + }) + + return configPath ? configPath : null +}