Files
tailwindcss/src/cli/build/plugin.js
T
Robin MalfaitandGitHub 668248e854 Separate stable and oxide engines (#10359)
* separate `stable` and `oxide` mode (package.json in this case)

* drop `install` script (we use a workspace now)

* change required engine to 16

* enable OXIDE by default

* ignore generated `oxide` files

* splitup package.json scripts into "public" and "private" scripts

Not ideal of course, but this should make it a tiny bit easier to know
which scripts _you_ as a developer / contributor have to run.

* drop `workspaces` from the `stable` engine

* drop `oxide` related build files from the `stable` engine

* drop `oxide` engine specific dependencies from the `stable` engine

* use the `oxide-node-api-shim` for the `stable` engine

* add little script to swap the engines

* drop `oxide:build` from `turbo` config

* configure `ci` for `stable` and `oxide` engines

- rename `nodejs.yml` -> `ci.yml`
- add `ci-stable.yml` (for stable mode and Node 12)
- ensure to use the `stable` engine in the `ci-stable.yml` workflow
- drop `oxide:___` specific scripts

* rename `release-insiders` to `release-insiders-stable`

This way we will be able to remove all files that contain `stable` once
we are ready.

* rename `release-insiders-oxide` to just `release-insiders`

* cleanup insider related workflows

* rename `release` -> `release-stable`

* rename `release-oxide` -> `release`

* change names of release workflows

* drop `oxide-` prefix from jobs

* inline node versions

* do not use `turbo` for the stable build

Can't use it because we don't have a workspace in the stable build.

* re-rename CI workflow

* encode default engine in relevant `package.json` files

* make Node 12 work

* increase `node-version` matrix

* make release workflows explicit (per engine)

* add `Oxide` to workflow name

* add integration tests for the `oxide` engine

* add integration tests for the `stable` engine

* run `oxide` integrations against node `18`

* run `stable` integration tests against node 18

We should test node 12 for tailwindcss, but integrations itself can run
against a newer version. In fact, we always ran them against node 16.

* use `localhost` instead of `0.0.0.0`

* ensure `webpack-4` works on Node 18

* run relese scripst directly

Instead of going via `npm`. It's a bit nicer and quicker!

* drop unused scripts

* sync package-lock.json

* ensure to generate the plugin list before running `jest`

We _could_ use an `npm run pretest`, but then you can't run `jest`
directly anymore (which is required for some tools like vscode
extensions).

* cleanup npm scripts

* drop pretend comments

* fix typo

* add `build:rust` as a pre-jest run script
2023-01-19 11:58:25 -05:00

440 lines
12 KiB
JavaScript

// @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 '../../lib/sharedState'
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'
import log from '../../util/log'
/**
*
* @param {string} [customPostCssPath ]
* @returns
*/
async function loadPostCssPlugins(customPostCssPath) {
let config = customPostCssPath
? await (async () => {
let file = path.resolve(customPostCssPath)
// Implementation, see: https://unpkg.com/browse/[email protected]/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<typeof createWatcher> | 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, content) {
if (this.watcher && configPath) {
this.refreshConfigDependencies(configPath)
}
let config = configPath ? require(configPath) : {}
// @ts-ignore
config = resolveConfig(config, { content: { files: [] } })
// Override content files if `--content` has been passed explicitly
if (content?.length > 0) {
config.content.files = content
}
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) {
if (env.OXIDE) {
content.push({
file,
extension: path.extname(file).slice(1),
})
} else {
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: htmlContent, extension = 'html' } of rawContent) {
content.push({ content: htmlContent, extension })
}
return content
},
getContext({ createContext, cliConfigPath, root, result, content }) {
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, content)
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')
}
for (let file of this.readContentPaths()) {
this.context.changedContent.push(file)
}
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()
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']
}
}
let content = args['--content']?.split(/(?<!{[^}]+),/) ?? []
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,
content,
})
}
})(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 (!state.watcher) {
return result
}
env.DEBUG && console.time('Recording PostCSS dependencies')
for (let message of result.messages) {
if (message.type === 'dependency') {
state.contextDependencies.add(message.file)
}
}
env.DEBUG && console.timeEnd('Recording PostCSS dependencies')
// TODO: This needs to be in a different spot
env.DEBUG && console.time('Watch new files')
state.watcher.refreshWatchedFiles()
env.DEBUG && console.timeEnd('Watch new files')
return result
})
.then((result) => {
if (!output) {
process.stdout.write(result.css)
return
}
return Promise.all([
outputFile(result.opts.to, result.css),
result.map && outputFile(result.opts.to + '.map', result.map.toString()),
])
})
.then(() => {
let end = process.hrtime.bigint()
console.error()
console.error('Done in', (end - start) / BigInt(1e6) + 'ms.')
})
.then(
() => {},
(err) => {
// TODO: If an initial build fails we can't easily pick up any PostCSS dependencies
// that were collected before the error occurred
// The result is not stored on the error so we have to store it externally
// and pull the messages off of it here somehow
// This results in a less than ideal DX because the watcher will not pick up
// changes to imported CSS if one of them caused an error during the initial build
// If you fix it and then save the main CSS file so there's no error
// The watcher will start watching the imported CSS files and will be
// resilient to future errors.
console.error(err)
}
)
}
/**
* @param {{file: string, content(): Promise<string>, 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<string>, 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()
},
}
}