first version

This commit is contained in:
Annika
2022-08-09 16:47:50 -06:00
commit 1826a6d70d
1039 changed files with 197766 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
export function lazyPostcss() {
return require('postcss')
}
export function lazyPostcssImport() {
return require('postcss-import')
}
export function lazyAutoprefixer() {
return require('autoprefixer')
}
export function lazyCssnano() {
return require('cssnano')
}
+915
View File
@@ -0,0 +1,915 @@
#!/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 { validateConfig } from './util/validateConfig.js'
let env = {
DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0',
}
function isESM() {
const pkgPath = path.resolve('./package.json')
try {
let pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
return pkg.type && pkg.type === 'module'
} catch (err) {
return false
}
}
let configs = isESM()
? {
tailwind: 'tailwind.config.cjs',
postcss: 'postcss.config.cjs',
}
: {
tailwind: 'tailwind.config.js',
postcss: 'postcss.config.js',
}
// ---
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) => {
for (let option of options) {
let parsed = option(value)
if (parsed === value) {
return parsed
}
}
throw new Error('...')
},
{ manualParsing: true }
)
}
function loadPostcss() {
// Try to load a local `postcss` version first
try {
return require('postcss')
} catch {}
return lazyPostcss()
}
let commands = {
init: {
run: init,
args: {
'--full': { type: Boolean, description: `Initialize a full \`${configs.tailwind}\` file` },
'--postcss': { type: Boolean, description: `Initialize a \`${configs.postcss}\` file` },
'-f': '--full',
'-p': '--postcss',
},
},
build: {
run: build,
args: {
'--input': { type: String, description: 'Input file' },
'--output': { type: String, description: 'Output file' },
'--watch': { type: Boolean, description: 'Watch for changes and rebuild as needed' },
'--poll': {
type: Boolean,
description: 'Use polling instead of filesystem events when watching',
},
'--content': {
type: String,
description: 'Content paths to use for removing unused classes',
},
'--purge': {
type: String,
deprecated: true,
},
'--postcss': {
type: oneOf(String, Boolean),
description: 'Load custom PostCSS configuration',
},
'--minify': { type: Boolean, description: 'Minify the output' },
'--config': {
type: String,
description: 'Path to a custom config file',
},
'--no-autoprefixer': {
type: Boolean,
description: 'Disable autoprefixer',
},
'-c': '--config',
'-i': '--input',
'-o': '--output',
'-m': '--minify',
'-w': '--watch',
'-p': '--poll',
},
},
}
let sharedFlags = {
'--help': { type: Boolean, description: 'Display usage information' },
'-h': '--help',
}
if (
process.stdout.isTTY /* Detect redirecting output to a file */ &&
(process.argv[2] === undefined ||
process.argv.slice(2).every((flag) => sharedFlags[flag] !== undefined))
) {
help({
usage: [
'tailwindcss [--input input.css] [--output output.css] [--watch] [options...]',
'tailwindcss init [--full] [--postcss] [options...]',
],
commands: Object.keys(commands)
.filter((command) => command !== 'build')
.map((command) => `${command} [options]`),
options: { ...commands.build.args, ...sharedFlags },
})
process.exit(0)
}
let command = ((arg = '') => (arg.startsWith('-') ? undefined : arg))(process.argv[2]) || 'build'
if (commands[command] === undefined) {
if (fs.existsSync(path.resolve(command))) {
// TODO: Deprecate this in future versions
// Check if non-existing command, might be a file.
command = 'build'
} else {
help({
message: `Invalid command: ${command}`,
usage: ['tailwindcss <command> [options]'],
commands: Object.keys(commands)
.filter((command) => command !== 'build')
.map((command) => `${command} [options]`),
options: sharedFlags,
})
process.exit(1)
}
}
// Execute command
let { args: flags, run } = commands[command]
let args = (() => {
try {
let result = arg(
Object.fromEntries(
Object.entries({ ...flags, ...sharedFlags })
.filter(([_key, value]) => !value?.type?.manualParsing)
.map(([key, value]) => [key, typeof value === 'object' ? value.type : value])
),
{ permissive: true }
)
// Manual parsing of flags to allow for special flags like oneOf(Boolean, String)
for (let i = result['_'].length - 1; i >= 0; --i) {
let flag = result['_'][i]
if (!flag.startsWith('-')) continue
let flagName = flag
let handler = flags[flag]
// Resolve flagName & handler
while (typeof handler === 'string') {
flagName = handler
handler = flags[handler]
}
if (!handler) continue
let args = []
let offset = i + 1
// Parse args for current flag
while (result['_'][offset] && !result['_'][offset].startsWith('-')) {
args.push(result['_'][offset++])
}
// Cleanup manually parsed flags + args
result['_'].splice(i, 1 + args.length)
// Set the resolved value in the `result` object
result[flagName] = handler.type(
args.length === 0 ? undefined : args.length === 1 ? args[0] : args,
flagName
)
}
// Ensure that the `command` is always the first argument in the `args`.
// This is important so that we don't have to check if a default command
// (build) was used or not from within each plugin.
//
// E.g.: tailwindcss input.css -> _: ['build', 'input.css']
// E.g.: tailwindcss build input.css -> _: ['build', 'input.css']
if (result['_'][0] !== command) {
result['_'].unshift(command)
}
return result
} catch (err) {
if (err.code === 'ARG_UNKNOWN_OPTION') {
help({
message: err.message,
usage: ['tailwindcss <command> [options]'],
options: sharedFlags,
})
process.exit(1)
}
throw err
}
})()
if (args['--help']) {
help({
options: { ...flags, ...sharedFlags },
usage: [`tailwindcss ${command} [options]`],
})
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(/(?<!{[^}]+),/)
let resolvedConfig = resolveConfigInternal(config, { content: { files } })
resolvedConfig.content.files = files
resolvedConfig = validateConfig(resolvedConfig)
return resolvedConfig
}
let resolvedConfig = resolveConfigInternal(config)
resolvedConfig = validateConfig(resolvedConfig)
return resolvedConfig
}
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))
}
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)
if (input) {
contextDependencies.add(path.resolve(input))
}
watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], {
usePolling: shouldPoll,
interval: shouldPoll ? pollInterval : undefined,
ignoreInitial: true,
awaitWriteFinish: shouldCoalesceWriteEvents
? {
stabilityThreshold: 50,
pollInterval: pollInterval,
}
: false,
})
let chain = Promise.resolve()
watcher.on('change', async (file) => {
if (contextDependencies.has(file)) {
env.DEBUG && console.time('Resolve config')
context = null
config = refreshConfig(configPath)
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)
})
}
})
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 */
if (process.stdin.isTTY) {
process.stdin.on('end', () => process.exit(0))
process.stdin.resume()
}
startWatcher()
} else {
buildOnce()
}
}
+17
View File
@@ -0,0 +1,17 @@
import path from 'path'
export const cli = 'tailwind'
export const defaultConfigFile = './tailwind.config.js'
export const defaultPostCssConfigFile = './postcss.config.js'
export const cjsConfigFile = './tailwind.config.cjs'
export const cjsPostCssConfigFile = './postcss.config.cjs'
export const supportedConfigFiles = [cjsConfigFile, defaultConfigFile]
export const supportedPostCssConfigFile = [cjsPostCssConfigFile, defaultPostCssConfigFile]
export const defaultConfigStubFile = path.resolve(__dirname, '../stubs/defaultConfig.stub.js')
export const simpleConfigStubFile = path.resolve(__dirname, '../stubs/simpleConfig.stub.js')
export const defaultPostCssConfigStubFile = path.resolve(
__dirname,
'../stubs/defaultPostCssConfig.stub.js'
)
+1
View File
@@ -0,0 +1 @@
export default ["preflight","container","accessibility","pointerEvents","visibility","position","inset","isolation","zIndex","order","gridColumn","gridColumnStart","gridColumnEnd","gridRow","gridRowStart","gridRowEnd","float","clear","margin","boxSizing","display","aspectRatio","height","maxHeight","minHeight","width","minWidth","maxWidth","flex","flexShrink","flexGrow","flexBasis","tableLayout","borderCollapse","borderSpacing","transformOrigin","translate","rotate","skew","scale","transform","animation","cursor","touchAction","userSelect","resize","scrollSnapType","scrollSnapAlign","scrollSnapStop","scrollMargin","scrollPadding","listStylePosition","listStyleType","appearance","columns","breakBefore","breakInside","breakAfter","gridAutoColumns","gridAutoFlow","gridAutoRows","gridTemplateColumns","gridTemplateRows","flexDirection","flexWrap","placeContent","placeItems","alignContent","alignItems","justifyContent","justifyItems","gap","space","divideWidth","divideStyle","divideColor","divideOpacity","placeSelf","alignSelf","justifySelf","overflow","overscrollBehavior","scrollBehavior","textOverflow","whitespace","wordBreak","borderRadius","borderWidth","borderStyle","borderColor","borderOpacity","backgroundColor","backgroundOpacity","backgroundImage","gradientColorStops","boxDecorationBreak","backgroundSize","backgroundAttachment","backgroundClip","backgroundPosition","backgroundRepeat","backgroundOrigin","fill","stroke","strokeWidth","objectFit","objectPosition","padding","textAlign","textIndent","verticalAlign","fontFamily","fontSize","fontWeight","textTransform","fontStyle","fontVariantNumeric","lineHeight","letterSpacing","textColor","textOpacity","textDecoration","textDecorationColor","textDecorationStyle","textDecorationThickness","textUnderlineOffset","fontSmoothing","placeholderColor","placeholderOpacity","caretColor","accentColor","opacity","backgroundBlendMode","mixBlendMode","boxShadow","boxShadowColor","outlineStyle","outlineWidth","outlineOffset","outlineColor","ringWidth","ringColor","ringOpacity","ringOffsetWidth","ringOffsetColor","blur","brightness","contrast","dropShadow","grayscale","hueRotate","invert","saturate","sepia","filter","backdropBlur","backdropBrightness","backdropContrast","backdropGrayscale","backdropHueRotate","backdropInvert","backdropOpacity","backdropSaturate","backdropSepia","backdropFilter","transitionProperty","transitionDelay","transitionDuration","transitionTimingFunction","willChange","content"]
+2442
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
MIT License
Copyright (c) Nicolas Gallagher
Copyright (c) Jonathan Neal
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
Copyright (c) Adam Wathan
Copyright (c) Jonathan Reinink
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+360
View File
@@ -0,0 +1,360 @@
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box; /* 1 */
border-width: 0; /* 2 */
border-style: solid; /* 2 */
border-color: theme('borderColor.DEFAULT', currentColor); /* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
*/
html {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-tab-size: 4; /* 3 */
tab-size: 4; /* 3 */
font-family: theme('fontFamily.sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); /* 4 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0; /* 1 */
line-height: inherit; /* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: theme('fontFamily.mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); /* 1 */
font-size: 1em; /* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
font-weight: inherit; /* 1 */
line-height: inherit; /* 1 */
color: inherit; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button; /* 1 */
background-color: transparent; /* 2 */
background-image: none; /* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::placeholder,
textarea::placeholder {
opacity: 1; /* 1 */
color: theme('colors.gray.400', #9ca3af); /* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
+54
View File
@@ -0,0 +1,54 @@
import colors from 'picocolors'
import log from './util/log'
let defaults = {
optimizeUniversalDefaults: false,
}
let featureFlags = {
future: ['hoverOnlyWhenSupported', 'respectDefaultRingColorOpacity'],
experimental: ['optimizeUniversalDefaults', 'matchVariant' /* , 'variantGrouping' */],
}
export function flagEnabled(config, flag) {
if (featureFlags.future.includes(flag)) {
return config.future === 'all' || (config?.future?.[flag] ?? defaults[flag] ?? false)
}
if (featureFlags.experimental.includes(flag)) {
return (
config.experimental === 'all' || (config?.experimental?.[flag] ?? defaults[flag] ?? false)
)
}
return false
}
function experimentalFlagsEnabled(config) {
if (config.experimental === 'all') {
return featureFlags.experimental
}
return Object.keys(config?.experimental ?? {}).filter(
(flag) => featureFlags.experimental.includes(flag) && config.experimental[flag]
)
}
export function issueFlagNotices(config) {
if (process.env.JEST_WORKER_ID !== undefined) {
return
}
if (experimentalFlagsEnabled(config).length > 0) {
let changes = experimentalFlagsEnabled(config)
.map((s) => colors.yellow(s))
.join(', ')
log.warn('experimental-flags-enabled', [
`You have enabled experimental features: ${changes}`,
'Experimental features in Tailwind CSS are not covered by semver, may introduce breaking changes, and can change at any time.',
])
}
}
export default featureFlags
+42
View File
@@ -0,0 +1,42 @@
import setupTrackingContext from './lib/setupTrackingContext'
import processTailwindFeatures from './processTailwindFeatures'
import { env } from './lib/sharedState'
module.exports = function tailwindcss(configOrPath) {
return {
postcssPlugin: 'tailwindcss',
plugins: [
env.DEBUG &&
function (root) {
console.log('\n')
console.time('JIT TOTAL')
return root
},
function (root, result) {
let context = setupTrackingContext(configOrPath)
if (root.type === 'document') {
let roots = root.nodes.filter((node) => node.type === 'root')
for (const root of roots) {
if (root.type === 'root') {
processTailwindFeatures(context)(root, result)
}
}
return
}
processTailwindFeatures(context)(root, result)
},
env.DEBUG &&
function (root) {
console.timeEnd('JIT TOTAL')
console.log('\n')
return root
},
].filter(Boolean),
}
}
module.exports.postcss = true
+52
View File
@@ -0,0 +1,52 @@
import crypto from 'crypto'
import * as sharedState from './sharedState'
/**
* Calculate the hash of a string.
*
* This doesn't need to be cryptographically secure or
* anything like that since it's used only to detect
* when the CSS changes to invalidate the context.
*
* This is wrapped in a try/catch because it's really dependent
* on how Node itself is build and the environment and OpenSSL
* version / build that is installed on the user's machine.
*
* Based on the environment this can just outright fail.
*
* See https://github.com/nodejs/node/issues/40455
*
* @param {string} str
*/
function getHash(str) {
try {
return crypto.createHash('md5').update(str, 'utf-8').digest('binary')
} catch (err) {
return ''
}
}
/**
* Determine if the CSS tree is different from the
* previous version for the given `sourcePath`.
*
* @param {string} sourcePath
* @param {import('postcss').Node} root
*/
export function hasContentChanged(sourcePath, root) {
let css = root.toString()
// We only care about files with @tailwind directives
// Other files use an existing context
if (!css.includes('@tailwind')) {
return false
}
let existingHash = sharedState.sourceHashMap.get(sourcePath)
let rootHash = getHash(css)
let didChange = existingHash !== rootHash
sharedState.sourceHashMap.set(sourcePath, rootHash)
return didChange
}
+58
View File
@@ -0,0 +1,58 @@
let comparisonMap = {
atrule: ['name', 'params'],
rule: ['selector'],
}
let types = new Set(Object.keys(comparisonMap))
export default function collapseAdjacentRules() {
function collapseRulesIn(root) {
let currentRule = null
root.each((node) => {
if (!types.has(node.type)) {
currentRule = null
return
}
if (currentRule === null) {
currentRule = node
return
}
let properties = comparisonMap[node.type]
if (node.type === 'atrule' && node.name === 'font-face') {
currentRule = node
} else if (
properties.every(
(property) =>
(node[property] ?? '').replace(/\s+/g, ' ') ===
(currentRule[property] ?? '').replace(/\s+/g, ' ')
)
) {
// An AtRule may not have children (for example if we encounter duplicate @import url(…) rules)
if (node.nodes) {
currentRule.append(node.nodes)
}
node.remove()
} else {
currentRule = node
}
})
// After we've collapsed adjacent rules & at-rules, we need to collapse
// adjacent rules & at-rules that are children of at-rules.
// We do not care about nesting rules because Tailwind CSS
// explicitly does not handle rule nesting on its own as
// the user is expected to use a nesting plugin
root.each((node) => {
if (node.type === 'atrule') {
collapseRulesIn(node)
}
})
}
return (root) => {
collapseRulesIn(root)
}
}
+93
View File
@@ -0,0 +1,93 @@
export default function collapseDuplicateDeclarations() {
return (root) => {
root.walkRules((node) => {
let seen = new Map()
let droppable = new Set([])
let byProperty = new Map()
node.walkDecls((decl) => {
// This could happen if we have nested selectors. In that case the
// parent will loop over all its declarations but also the declarations
// of nested rules. With this we ensure that we are shallowly checking
// declarations.
if (decl.parent !== node) {
return
}
if (seen.has(decl.prop)) {
// Exact same value as what we have seen so far
if (seen.get(decl.prop).value === decl.value) {
// Keep the last one, drop the one we've seen so far
droppable.add(seen.get(decl.prop))
// Override the existing one with the new value. This is necessary
// so that if we happen to have more than one declaration with the
// same value, that we keep removing the previous one. Otherwise we
// will only remove the *first* one.
seen.set(decl.prop, decl)
return
}
// Not the same value, so we need to check if we can merge it so
// let's collect it first.
if (!byProperty.has(decl.prop)) {
byProperty.set(decl.prop, new Set())
}
byProperty.get(decl.prop).add(seen.get(decl.prop))
byProperty.get(decl.prop).add(decl)
}
seen.set(decl.prop, decl)
})
// Drop all the duplicate declarations with the exact same value we've
// already seen so far.
for (let decl of droppable) {
decl.remove()
}
// Analyze the declarations based on its unit, drop all the declarations
// with the same unit but the last one in the list.
for (let declarations of byProperty.values()) {
let byUnit = new Map()
for (let decl of declarations) {
let unit = resolveUnit(decl.value)
if (unit === null) {
// We don't have a unit, so should never try and collapse this
// value. This is because we can't know how to do it in a correct
// way (e.g.: overrides for older browsers)
continue
}
if (!byUnit.has(unit)) {
byUnit.set(unit, new Set())
}
byUnit.get(unit).add(decl)
}
for (let declarations of byUnit.values()) {
// Get all but the last one
let removableDeclarations = Array.from(declarations).slice(0, -1)
for (let decl of removableDeclarations) {
decl.remove()
}
}
}
})
}
}
let UNITLESS_NUMBER = Symbol('unitless-number')
function resolveUnit(input) {
let result = /^-?\d*.?\d+([\w%]+)?$/g.exec(input)
if (result) {
return result[1] ?? UNITLESS_NUMBER
}
return null
}
+208
View File
@@ -0,0 +1,208 @@
import { flagEnabled } from '../featureFlags'
import * as regex from './regex'
export function defaultExtractor(context) {
let patterns = Array.from(buildRegExps(context))
/**
* @param {string} content
*/
return (content) => {
/** @type {(string|string)[]} */
let results = []
for (let pattern of patterns) {
results = [...results, ...(content.match(pattern) ?? [])]
}
return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
}
}
function* buildRegExps(context) {
let separator = context.tailwindConfig.separator
let variantGroupingEnabled = flagEnabled(context.tailwindConfig, 'variantGrouping')
let prefix =
context.tailwindConfig.prefix !== ''
? regex.optional(regex.pattern([/-?/, regex.escape(context.tailwindConfig.prefix)]))
: ''
let utility = regex.any([
// Arbitrary properties
/\[[^\s:'"`]+:[^\s\]]+\]/,
// Utilities
regex.pattern([
// Utility Name / Group Name
/-?(?:\w+)/,
// Normal/Arbitrary values
regex.optional(
regex.any([
regex.pattern([
// Arbitrary values
/-(?:\w+-)*\[[^\s:]+\]/,
// Not immediately followed by an `{[(`
/(?![{([]])/,
// optionally followed by an opacity modifier
/(?:\/[^\s'"`\\><$]*)?/,
]),
regex.pattern([
// Arbitrary values
/-(?:\w+-)*\[[^\s]+\]/,
// Not immediately followed by an `{[(`
/(?![{([]])/,
// optionally followed by an opacity modifier
/(?:\/[^\s'"`\\$]*)?/,
]),
// Normal values w/o quotes — may include an opacity modifier
/[-\/][^\s'"`\\$={><]*/,
])
),
]),
])
let variantPatterns = [
// Without quotes
regex.any([
regex.pattern([/([^\s"'`\[\\]+-)?\[[^\s"'`]+\]/, separator]),
regex.pattern([/[^\s"'`\[\\]+/, separator]),
]),
// With quotes allowed
regex.any([
regex.pattern([/([^\s"'`\[\\]+-)?\[[^\s`]+\]/, separator]),
regex.pattern([/[^\s`\[\\]+/, separator]),
]),
]
for (const variantPattern of variantPatterns) {
yield regex.pattern([
// Variants
'((?=((',
variantPattern,
')+))\\2)?',
// Important (optional)
/!?/,
prefix,
variantGroupingEnabled
? regex.any([
// Or any of those things but grouped separated by commas
regex.pattern([/\(/, utility, regex.zeroOrMore([/,/, utility]), /\)/]),
// Arbitrary properties, constrained utilities, arbitrary values, etc…
utility,
])
: utility,
])
}
// 5. Inner matches
yield /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g
}
// We want to capture any "special" characters
// AND the characters immediately following them (if there is one)
let SPECIALS = /([\[\]'"`])([^\[\]'"`])?/g
let ALLOWED_CLASS_CHARACTERS = /[^"'`\s<>\]]+/
/**
* Clips a string ensuring that parentheses, quotes, etc… are balanced
* Used for arbitrary values only
*
* We will go past the end of the balanced parens until we find a non-class character
*
* Depth matching behavior:
* w-[calc(100%-theme('spacing[some_key][1.5]'))]']
* ┬ ┬ ┬┬ ┬ ┬┬ ┬┬┬┬┬┬┬
* 1 2 3 4 34 3 210 END
* ╰────┴──────────┴────────┴────────┴┴───┴─┴┴┴
*
* @param {string} input
*/
function clipAtBalancedParens(input) {
// We are care about this for arbitrary values
if (!input.includes('-[')) {
return input
}
let depth = 0
let openStringTypes = []
// Find all parens, brackets, quotes, etc
// Stop when we end at a balanced pair
// This is naive and will treat mismatched parens as balanced
// This shouldn't be a problem in practice though
let matches = input.matchAll(SPECIALS)
// We can't use lookbehind assertions because we have to support Safari
// So, instead, we've emulated it using capture groups and we'll re-work the matches to accommodate
matches = Array.from(matches).flatMap((match) => {
const [, ...groups] = match
return groups.map((group, idx) =>
Object.assign([], match, {
index: match.index + idx,
0: group,
})
)
})
for (let match of matches) {
let char = match[0]
let inStringType = openStringTypes[openStringTypes.length - 1]
if (char === inStringType) {
openStringTypes.pop()
} else if (char === "'" || char === '"' || char === '`') {
openStringTypes.push(char)
}
if (inStringType) {
continue
} else if (char === '[') {
depth++
continue
} else if (char === ']') {
depth--
continue
}
// We've gone one character past the point where we should stop
// This means that there was an extra closing `]`
// We'll clip to just before it
if (depth < 0) {
return input.substring(0, match.index)
}
// We've finished balancing the brackets but there still may be characters that can be included
// For example in the class `text-[#336699]/[.35]`
// The depth goes to `0` at the closing `]` but goes up again at the `[`
// If we're at zero and encounter a non-class character then we clip the class there
if (depth === 0 && !ALLOWED_CLASS_CHARACTERS.test(char)) {
return input.substring(0, match.index)
}
}
return input
}
// Regular utilities
// {{modifier}:}*{namespace}{-{suffix}}*{/{opacityModifier}}?
// Arbitrary values
// {{modifier}:}*{namespace}-[{arbitraryValue}]{/{opacityModifier}}?
// arbitraryValue: no whitespace, balanced quotes unless within quotes, balanced brackets unless within quotes
// Arbitrary properties
// {{modifier}:}*[{validCssPropertyName}:{arbitraryValue}]
+39
View File
@@ -0,0 +1,39 @@
export default function (_context) {
return (root, result) => {
let found = false
root.walkAtRules('tailwind', (node) => {
if (found) return false
if (node.parent && node.parent.type !== 'root') {
found = true
node.warn(
result,
[
'Nested @tailwind rules were detected, but are not supported.',
"Consider using a prefix to scope Tailwind's classes: https://tailwindcss.com/docs/configuration#prefix",
'Alternatively, use the important selector strategy: https://tailwindcss.com/docs/configuration#selector-strategy',
].join('\n')
)
return false
}
})
root.walkRules((rule) => {
if (found) return false
rule.walkRules((nestedRule) => {
found = true
nestedRule.warn(
result,
[
'Nested CSS was detected, but CSS nesting has not been configured correctly.',
'Please enable a CSS nesting plugin *before* Tailwind in your configuration.',
'See how here: https://tailwindcss.com/docs/using-with-preprocessors#nesting',
].join('\n')
)
return false
})
})
}
}
+248
View File
@@ -0,0 +1,248 @@
import dlv from 'dlv'
import didYouMean from 'didyoumean'
import transformThemeValue from '../util/transformThemeValue'
import parseValue from 'postcss-value-parser'
import { normalizeScreens } from '../util/normalizeScreens'
import buildMediaQuery from '../util/buildMediaQuery'
import { toPath } from '../util/toPath'
import { withAlphaValue } from '../util/withAlphaVariable'
import { parseColorFormat } from '../util/pluginUtils'
function isObject(input) {
return typeof input === 'object' && input !== null
}
function findClosestExistingPath(theme, path) {
let parts = toPath(path)
do {
parts.pop()
if (dlv(theme, parts) !== undefined) break
} while (parts.length)
return parts.length ? parts : undefined
}
function pathToString(path) {
if (typeof path === 'string') return path
return path.reduce((acc, cur, i) => {
if (cur.includes('.')) return `${acc}[${cur}]`
return i === 0 ? cur : `${acc}.${cur}`
}, '')
}
function list(items) {
return items.map((key) => `'${key}'`).join(', ')
}
function listKeys(obj) {
return list(Object.keys(obj))
}
function validatePath(config, path, defaultValue, themeOpts = {}) {
const pathString = Array.isArray(path) ? pathToString(path) : path.replace(/^['"]+|['"]+$/g, '')
const pathSegments = Array.isArray(path) ? path : toPath(pathString)
const value = dlv(config.theme, pathSegments, defaultValue)
if (value === undefined) {
let error = `'${pathString}' does not exist in your theme config.`
const parentSegments = pathSegments.slice(0, -1)
const parentValue = dlv(config.theme, parentSegments)
if (isObject(parentValue)) {
const validKeys = Object.keys(parentValue).filter(
(key) => validatePath(config, [...parentSegments, key]).isValid
)
const suggestion = didYouMean(pathSegments[pathSegments.length - 1], validKeys)
if (suggestion) {
error += ` Did you mean '${pathToString([...parentSegments, suggestion])}'?`
} else if (validKeys.length > 0) {
error += ` '${pathToString(parentSegments)}' has the following valid keys: ${list(
validKeys
)}`
}
} else {
const closestPath = findClosestExistingPath(config.theme, pathString)
if (closestPath) {
const closestValue = dlv(config.theme, closestPath)
if (isObject(closestValue)) {
error += ` '${pathToString(closestPath)}' has the following keys: ${listKeys(
closestValue
)}`
} else {
error += ` '${pathToString(closestPath)}' is not an object.`
}
} else {
error += ` Your theme has the following top-level keys: ${listKeys(config.theme)}`
}
}
return {
isValid: false,
error,
}
}
if (
!(
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'function' ||
value instanceof String ||
value instanceof Number ||
Array.isArray(value)
)
) {
let error = `'${pathString}' was found but does not resolve to a string.`
if (isObject(value)) {
let validKeys = Object.keys(value).filter(
(key) => validatePath(config, [...pathSegments, key]).isValid
)
if (validKeys.length) {
error += ` Did you mean something like '${pathToString([...pathSegments, validKeys[0]])}'?`
}
}
return {
isValid: false,
error,
}
}
const [themeSection] = pathSegments
return {
isValid: true,
value: transformThemeValue(themeSection)(value, themeOpts),
}
}
function extractArgs(node, vNodes, functions) {
vNodes = vNodes.map((vNode) => resolveVNode(node, vNode, functions))
let args = ['']
for (let vNode of vNodes) {
if (vNode.type === 'div' && vNode.value === ',') {
args.push('')
} else {
args[args.length - 1] += parseValue.stringify(vNode)
}
}
return args
}
function resolveVNode(node, vNode, functions) {
if (vNode.type === 'function' && functions[vNode.value] !== undefined) {
let args = extractArgs(node, vNode.nodes, functions)
vNode.type = 'word'
vNode.value = functions[vNode.value](node, ...args)
}
return vNode
}
function resolveFunctions(node, input, functions) {
return parseValue(input)
.walk((vNode) => {
resolveVNode(node, vNode, functions)
})
.toString()
}
let nodeTypePropertyMap = {
atrule: 'params',
decl: 'value',
}
/**
* @param {string} path
* @returns {Iterable<[path: string, alpha: string|undefined]>}
*/
function* toPaths(path) {
// Strip quotes from beginning and end of string
// This allows the alpha value to be present inside of quotes
path = path.replace(/^['"]+|['"]+$/g, '')
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]+))$/)
let alpha = undefined
yield [path, undefined]
if (matches) {
path = matches[1]
alpha = matches[2]
yield [path, alpha]
}
}
/**
*
* @param {any} config
* @param {string} path
* @param {any} defaultValue
*/
function resolvePath(config, path, defaultValue) {
const results = Array.from(toPaths(path)).map(([path, alpha]) => {
return Object.assign(validatePath(config, path, defaultValue, { opacityValue: alpha }), {
resolvedPath: path,
alpha,
})
})
return results.find((result) => result.isValid) ?? results[0]
}
export default function ({ tailwindConfig: config }) {
let functions = {
theme: (node, path, ...defaultValue) => {
let { isValid, value, error, alpha } = resolvePath(
config,
path,
defaultValue.length ? defaultValue : undefined
)
if (!isValid) {
throw node.error(error)
}
let maybeColor = parseColorFormat(value)
let isColorFunction = maybeColor !== undefined && typeof maybeColor === 'function'
if (alpha !== undefined || isColorFunction) {
if (alpha === undefined) {
alpha = 1.0
}
value = withAlphaValue(maybeColor, alpha, maybeColor)
}
return value
},
screen: (node, screen) => {
screen = screen.replace(/^['"]+/g, '').replace(/['"]+$/g, '')
let screens = normalizeScreens(config.theme.screens)
let screenDefinition = screens.find(({ name }) => name === screen)
if (!screenDefinition) {
throw node.error(`The '${screen}' screen does not exist in your theme.`)
}
return buildMediaQuery(screenDefinition)
},
}
return (root) => {
root.walk((node) => {
let property = nodeTypePropertyMap[node.type]
if (property === undefined) {
return
}
node[property] = resolveFunctions(node, node[property], functions)
})
}
}
+545
View File
@@ -0,0 +1,545 @@
import postcss from 'postcss'
import parser from 'postcss-selector-parser'
import { resolveMatches } from './generateRules'
import bigSign from '../util/bigSign'
import escapeClassName from '../util/escapeClassName'
/** @typedef {Map<string, [any, import('postcss').Rule[]]>} ApplyCache */
function extractClasses(node) {
/** @type {Map<string, Set<string>>} */
let groups = new Map()
let container = postcss.root({ nodes: [node.clone()] })
container.walkRules((rule) => {
parser((selectors) => {
selectors.walkClasses((classSelector) => {
let parentSelector = classSelector.parent.toString()
let classes = groups.get(parentSelector)
if (!classes) {
groups.set(parentSelector, (classes = new Set()))
}
classes.add(classSelector.value)
})
}).processSync(rule.selector)
})
let normalizedGroups = Array.from(groups.values(), (classes) => Array.from(classes))
let classes = normalizedGroups.flat()
return Object.assign(classes, { groups: normalizedGroups })
}
let selectorExtractor = parser((root) => root.nodes.map((node) => node.toString()))
/**
* @param {string} ruleSelectors
*/
function extractSelectors(ruleSelectors) {
return selectorExtractor.transformSync(ruleSelectors)
}
function extractBaseCandidates(candidates, separator) {
let baseClasses = new Set()
for (let candidate of candidates) {
baseClasses.add(candidate.split(separator).pop())
}
return Array.from(baseClasses)
}
function prefix(context, selector) {
let prefix = context.tailwindConfig.prefix
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
}
function* pathToRoot(node) {
yield node
while (node.parent) {
yield node.parent
node = node.parent
}
}
/**
* Only clone the node itself and not its children
*
* @param {*} node
* @param {*} overrides
* @returns
*/
function shallowClone(node, overrides = {}) {
let children = node.nodes
node.nodes = []
let tmp = node.clone(overrides)
node.nodes = children
return tmp
}
/**
* Clone just the nodes all the way to the top that are required to represent
* this singular rule in the tree.
*
* For example, if we have CSS like this:
* ```css
* @media (min-width: 768px) {
* @supports (display: grid) {
* .foo {
* display: grid;
* grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
* }
* }
*
* @supports (backdrop-filter: blur(1px)) {
* .bar {
* backdrop-filter: blur(1px);
* }
* }
*
* .baz {
* color: orange;
* }
* }
* ```
*
* And we're cloning `.bar` it'll return a cloned version of what's required for just that single node:
*
* ```css
* @media (min-width: 768px) {
* @supports (backdrop-filter: blur(1px)) {
* .bar {
* backdrop-filter: blur(1px);
* }
* }
* }
* ```
*
* @param {import('postcss').Node} node
*/
function nestedClone(node) {
for (let parent of pathToRoot(node)) {
if (node === parent) {
continue
}
if (parent.type === 'root') {
break
}
node = shallowClone(parent, {
nodes: [node],
})
}
return node
}
/**
* @param {import('postcss').Root} root
*/
function buildLocalApplyCache(root, context) {
/** @type {ApplyCache} */
let cache = new Map()
let highestOffset = context.layerOrder.user >> 4n
root.walkRules((rule, idx) => {
// Ignore rules generated by Tailwind
for (let node of pathToRoot(rule)) {
if (node.raws.tailwind?.layer !== undefined) {
return
}
}
// Clone what's required to represent this singular rule in the tree
let container = nestedClone(rule)
for (let className of extractClasses(rule)) {
let list = cache.get(className) || []
cache.set(className, list)
list.push([
{
layer: 'user',
sort: BigInt(idx) + highestOffset,
important: false,
},
container,
])
}
})
return cache
}
/**
* @returns {ApplyCache}
*/
function buildApplyCache(applyCandidates, context) {
for (let candidate of applyCandidates) {
if (context.notClassCache.has(candidate) || context.applyClassCache.has(candidate)) {
continue
}
if (context.classCache.has(candidate)) {
context.applyClassCache.set(
candidate,
context.classCache.get(candidate).map(([meta, rule]) => [meta, rule.clone()])
)
continue
}
let matches = Array.from(resolveMatches(candidate, context))
if (matches.length === 0) {
context.notClassCache.add(candidate)
continue
}
context.applyClassCache.set(candidate, matches)
}
return context.applyClassCache
}
/**
* Build a cache only when it's first used
*
* @param {() => ApplyCache} buildCacheFn
* @returns {ApplyCache}
*/
function lazyCache(buildCacheFn) {
let cache = null
return {
get: (name) => {
cache = cache || buildCacheFn()
return cache.get(name)
},
has: (name) => {
cache = cache || buildCacheFn()
return cache.has(name)
},
}
}
/**
* Take a series of multiple caches and merge
* them so they act like one large cache
*
* @param {ApplyCache[]} caches
* @returns {ApplyCache}
*/
function combineCaches(caches) {
return {
get: (name) => caches.flatMap((cache) => cache.get(name) || []),
has: (name) => caches.some((cache) => cache.has(name)),
}
}
function extractApplyCandidates(params) {
let candidates = params.split(/[\s\t\n]+/g)
if (candidates[candidates.length - 1] === '!important') {
return [candidates.slice(0, -1), true]
}
return [candidates, false]
}
function processApply(root, context, localCache) {
let applyCandidates = new Set()
// Collect all @apply rules and candidates
let applies = []
root.walkAtRules('apply', (rule) => {
let [candidates] = extractApplyCandidates(rule.params)
for (let util of candidates) {
applyCandidates.add(util)
}
applies.push(rule)
})
// Start the @apply process if we have rules with @apply in them
if (applies.length === 0) {
return
}
// Fill up some caches!
let applyClassCache = combineCaches([localCache, buildApplyCache(applyCandidates, context)])
/**
* When we have an apply like this:
*
* .abc {
* @apply hover:font-bold;
* }
*
* What we essentially will do is resolve to this:
*
* .abc {
* @apply .hover\:font-bold:hover {
* font-weight: 500;
* }
* }
*
* Notice that the to-be-applied class is `.hover\:font-bold:hover` and that the utility candidate was `hover:font-bold`.
* What happens in this function is that we prepend a `.` and escape the candidate.
* This will result in `.hover\:font-bold`
* Which means that we can replace `.hover\:font-bold` with `.abc` in `.hover\:font-bold:hover` resulting in `.abc:hover`
*/
// TODO: Should we use postcss-selector-parser for this instead?
function replaceSelector(selector, utilitySelectors, candidate) {
let needle = `.${escapeClassName(candidate)}`
let needles = [...new Set([needle, needle.replace(/\\2c /g, '\\,')])]
let utilitySelectorsList = extractSelectors(utilitySelectors)
return extractSelectors(selector)
.map((s) => {
let replaced = []
for (let utilitySelector of utilitySelectorsList) {
let replacedSelector = utilitySelector
for (const needle of needles) {
replacedSelector = replacedSelector.replace(needle, s)
}
if (replacedSelector === utilitySelector) {
continue
}
replaced.push(replacedSelector)
}
return replaced.join(', ')
})
.join(', ')
}
let perParentApplies = new Map()
// Collect all apply candidates and their rules
for (let apply of applies) {
let [candidates] = perParentApplies.get(apply.parent) || [[], apply.source]
perParentApplies.set(apply.parent, [candidates, apply.source])
let [applyCandidates, important] = extractApplyCandidates(apply.params)
if (apply.parent.type === 'atrule') {
if (apply.parent.name === 'screen') {
const screenType = apply.parent.params
throw apply.error(
`@apply is not supported within nested at-rules like @screen. We suggest you write this as @apply ${applyCandidates
.map((c) => `${screenType}:${c}`)
.join(' ')} instead.`
)
}
throw apply.error(
`@apply is not supported within nested at-rules like @${apply.parent.name}. You can fix this by un-nesting @${apply.parent.name}.`
)
}
for (let applyCandidate of applyCandidates) {
if ([prefix(context, 'group'), prefix(context, 'peer')].includes(applyCandidate)) {
// TODO: Link to specific documentation page with error code.
throw apply.error(`@apply should not be used with the '${applyCandidate}' utility`)
}
if (!applyClassCache.has(applyCandidate)) {
throw apply.error(
`The \`${applyCandidate}\` class does not exist. If \`${applyCandidate}\` is a custom class, make sure it is defined within a \`@layer\` directive.`
)
}
let rules = applyClassCache.get(applyCandidate)
candidates.push([applyCandidate, important, rules])
}
}
for (const [parent, [candidates, atApplySource]] of perParentApplies) {
let siblings = []
for (let [applyCandidate, important, rules] of candidates) {
let potentialApplyCandidates = [
applyCandidate,
...extractBaseCandidates([applyCandidate], context.tailwindConfig.separator),
]
for (let [meta, node] of rules) {
let parentClasses = extractClasses(parent)
let nodeClasses = extractClasses(node)
// When we encounter a rule like `.dark .a, .b { … }` we only want to be left with `[.dark, .a]` if the base applyCandidate is `.a` or with `[.b]` if the base applyCandidate is `.b`
// So we've split them into groups
nodeClasses = nodeClasses.groups
.filter((classList) =>
classList.some((className) => potentialApplyCandidates.includes(className))
)
.flat()
// Add base utility classes from the @apply node to the list of
// classes to check whether it intersects and therefore results in a
// circular dependency or not.
//
// E.g.:
// .foo {
// @apply hover:a; // This applies "a" but with a modifier
// }
//
// We only have to do that with base classes of the `node`, not of the `parent`
// E.g.:
// .hover\:foo {
// @apply bar;
// }
// .bar {
// @apply foo;
// }
//
// This should not result in a circular dependency because we are
// just applying `.foo` and the rule above is `.hover\:foo` which is
// unrelated. However, if we were to apply `hover:foo` then we _did_
// have to include this one.
nodeClasses = nodeClasses.concat(
extractBaseCandidates(nodeClasses, context.tailwindConfig.separator)
)
let intersects = parentClasses.some((selector) => nodeClasses.includes(selector))
if (intersects) {
throw node.error(
`You cannot \`@apply\` the \`${applyCandidate}\` utility here because it creates a circular dependency.`
)
}
let root = postcss.root({ nodes: [node.clone()] })
// Make sure every node in the entire tree points back at the @apply rule that generated it
root.walk((node) => {
node.source = atApplySource
})
let canRewriteSelector =
node.type !== 'atrule' || (node.type === 'atrule' && node.name !== 'keyframes')
if (canRewriteSelector) {
root.walkRules((rule) => {
// Let's imagine you have the following structure:
//
// .foo {
// @apply bar;
// }
//
// @supports (a: b) {
// .bar {
// color: blue
// }
//
// .something-unrelated {}
// }
//
// In this case we want to apply `.bar` but it happens to be in
// an atrule node. We clone that node instead of the nested one
// because we still want that @supports rule to be there once we
// applied everything.
//
// However it happens to be that the `.something-unrelated` is
// also in that same shared @supports atrule. This is not good,
// and this should not be there. The good part is that this is
// a clone already and it can be safely removed. The question is
// how do we know we can remove it. Basically what we can do is
// match it against the applyCandidate that you want to apply. If
// it doesn't match the we can safely delete it.
//
// If we didn't do this, then the `replaceSelector` function
// would have replaced this with something that didn't exist and
// therefore it removed the selector altogether. In this specific
// case it would result in `{}` instead of `.something-unrelated {}`
if (!extractClasses(rule).some((candidate) => candidate === applyCandidate)) {
rule.remove()
return
}
// Strip the important selector from the parent selector if at the beginning
let importantSelector =
typeof context.tailwindConfig.important === 'string'
? context.tailwindConfig.important
: null
// We only want to move the "important" selector if this is a Tailwind-generated utility
// We do *not* want to do this for user CSS that happens to be structured the same
let isGenerated = parent.raws.tailwind !== undefined
let parentSelector =
isGenerated && importantSelector && parent.selector.indexOf(importantSelector) === 0
? parent.selector.slice(importantSelector.length)
: parent.selector
rule.selector = replaceSelector(parentSelector, rule.selector, applyCandidate)
// And then re-add it if it was removed
if (importantSelector && parentSelector !== parent.selector) {
rule.selector = `${importantSelector} ${rule.selector}`
}
rule.walkDecls((d) => {
d.important = meta.important || important
})
})
}
// It could be that the node we were inserted was removed because the class didn't match
// If that was the *only* rule in the parent, then we have nothing add so we skip it
if (!root.nodes[0]) {
continue
}
// Insert it
siblings.push([
// Ensure that when we are sorting, that we take the layer order into account
{ ...meta, sort: meta.sort | context.layerOrder[meta.layer] },
root.nodes[0],
])
}
}
// Inject the rules, sorted, correctly
let nodes = siblings.sort(([a], [z]) => bigSign(a.sort - z.sort)).map((s) => s[1])
// `parent` refers to the node at `.abc` in: .abc { @apply mt-2 }
parent.after(nodes)
}
for (let apply of applies) {
// If there are left-over declarations, just remove the @apply
if (apply.parent.nodes.length > 1) {
apply.remove()
} else {
// The node is empty, drop the full node
apply.parent.remove()
}
}
// Do it again, in case we have other `@apply` rules
processApply(root, context, localCache)
}
export default function expandApplyAtRules(context) {
return (root) => {
// Build a cache of the user's CSS so we can use it to resolve classes used by @apply
let localCache = lazyCache(() => buildLocalApplyCache(root, context))
processApply(root, context, localCache)
}
}
+292
View File
@@ -0,0 +1,292 @@
import LRU from 'quick-lru'
import * as sharedState from './sharedState'
import { generateRules } from './generateRules'
import bigSign from '../util/bigSign'
import log from '../util/log'
import cloneNodes from '../util/cloneNodes'
import { defaultExtractor } from './defaultExtractor'
let env = sharedState.env
const builtInExtractors = {
DEFAULT: defaultExtractor,
}
const builtInTransformers = {
DEFAULT: (content) => content,
svelte: (content) => content.replace(/(?:^|\s)class:/g, ' '),
}
function getExtractor(context, fileExtension) {
let extractors = context.tailwindConfig.content.extract
return (
extractors[fileExtension] ||
extractors.DEFAULT ||
builtInExtractors[fileExtension] ||
builtInExtractors.DEFAULT(context)
)
}
function getTransformer(tailwindConfig, fileExtension) {
let transformers = tailwindConfig.content.transform
return (
transformers[fileExtension] ||
transformers.DEFAULT ||
builtInTransformers[fileExtension] ||
builtInTransformers.DEFAULT
)
}
let extractorCache = new WeakMap()
// Scans template contents for possible classes. This is a hot path on initial build but
// not too important for subsequent builds. The faster the better though — if we can speed
// up these regexes by 50% that could cut initial build time by like 20%.
function getClassCandidates(content, extractor, candidates, seen) {
if (!extractorCache.has(extractor)) {
extractorCache.set(extractor, new LRU({ maxSize: 25000 }))
}
for (let line of content.split('\n')) {
line = line.trim()
if (seen.has(line)) {
continue
}
seen.add(line)
if (extractorCache.get(extractor).has(line)) {
for (let match of extractorCache.get(extractor).get(line)) {
candidates.add(match)
}
} else {
let extractorMatches = extractor(line).filter((s) => s !== '!*')
let lineMatchesSet = new Set(extractorMatches)
for (let match of lineMatchesSet) {
candidates.add(match)
}
extractorCache.get(extractor).set(line, lineMatchesSet)
}
}
}
function buildStylesheet(rules, context) {
let sortedRules = rules.sort(([a], [z]) => bigSign(a - z))
let returnValue = {
base: new Set(),
defaults: new Set(),
components: new Set(),
utilities: new Set(),
variants: new Set(),
// All the CSS that is not Tailwind related can be put in this bucket. This
// will make it easier to later use this information when we want to
// `@apply` for example. The main reason we do this here is because we
// still need to make sure the order is correct. Last but not least, we
// will make sure to always re-inject this section into the css, even if
// certain rules were not used. This means that it will look like a no-op
// from the user's perspective, but we gathered all the useful information
// we need.
user: new Set(),
}
for (let [sort, rule] of sortedRules) {
if (sort >= context.minimumScreen) {
returnValue.variants.add(rule)
continue
}
if (sort & context.layerOrder.base) {
returnValue.base.add(rule)
continue
}
if (sort & context.layerOrder.defaults) {
returnValue.defaults.add(rule)
continue
}
if (sort & context.layerOrder.components) {
returnValue.components.add(rule)
continue
}
if (sort & context.layerOrder.utilities) {
returnValue.utilities.add(rule)
continue
}
if (sort & context.layerOrder.user) {
returnValue.user.add(rule)
continue
}
}
return returnValue
}
export default function expandTailwindAtRules(context) {
return (root) => {
let layerNodes = {
base: null,
components: null,
utilities: null,
variants: null,
}
root.walkAtRules((rule) => {
// Make sure this file contains Tailwind directives. If not, we can save
// a lot of work and bail early. Also we don't have to register our touch
// file as a dependency since the output of this CSS does not depend on
// the source of any templates. Think Vue <style> blocks for example.
if (rule.name === 'tailwind') {
if (Object.keys(layerNodes).includes(rule.params)) {
layerNodes[rule.params] = rule
}
}
})
if (Object.values(layerNodes).every((n) => n === null)) {
return root
}
// ---
// Find potential rules in changed files
let candidates = new Set([sharedState.NOT_ON_DEMAND])
let seen = new Set()
env.DEBUG && console.time('Reading changed files')
for (let { content, extension } of context.changedContent) {
let transformer = getTransformer(context.tailwindConfig, extension)
let extractor = getExtractor(context, extension)
getClassCandidates(transformer(content), extractor, candidates, seen)
}
env.DEBUG && console.timeEnd('Reading changed files')
// ---
// Generate the actual CSS
let classCacheCount = context.classCache.size
env.DEBUG && console.time('Generate rules')
let rules = generateRules(candidates, context)
env.DEBUG && console.timeEnd('Generate rules')
// We only ever add to the classCache, so if it didn't grow, there is nothing new.
env.DEBUG && console.time('Build stylesheet')
if (context.stylesheetCache === null || context.classCache.size !== classCacheCount) {
for (let rule of rules) {
context.ruleCache.add(rule)
}
context.stylesheetCache = buildStylesheet([...context.ruleCache], context)
}
env.DEBUG && console.timeEnd('Build stylesheet')
let {
defaults: defaultNodes,
base: baseNodes,
components: componentNodes,
utilities: utilityNodes,
variants: screenNodes,
} = context.stylesheetCache
// ---
// Replace any Tailwind directives with generated CSS
if (layerNodes.base) {
layerNodes.base.before(
cloneNodes([...baseNodes, ...defaultNodes], layerNodes.base.source, {
layer: 'base',
})
)
layerNodes.base.remove()
}
if (layerNodes.components) {
layerNodes.components.before(
cloneNodes([...componentNodes], layerNodes.components.source, {
layer: 'components',
})
)
layerNodes.components.remove()
}
if (layerNodes.utilities) {
layerNodes.utilities.before(
cloneNodes([...utilityNodes], layerNodes.utilities.source, {
layer: 'utilities',
})
)
layerNodes.utilities.remove()
}
// We do post-filtering to not alter the emitted order of the variants
const variantNodes = Array.from(screenNodes).filter((node) => {
const parentLayer = node.raws.tailwind?.parentLayer
if (parentLayer === 'components') {
return layerNodes.components !== null
}
if (parentLayer === 'utilities') {
return layerNodes.utilities !== null
}
return true
})
if (layerNodes.variants) {
layerNodes.variants.before(
cloneNodes(variantNodes, layerNodes.variants.source, {
layer: 'variants',
})
)
layerNodes.variants.remove()
} else if (variantNodes.length > 0) {
root.append(
cloneNodes(variantNodes, root.source, {
layer: 'variants',
})
)
}
// If we've got a utility layer and no utilities are generated there's likely something wrong
const hasUtilityVariants = variantNodes.some(
(node) => node.raws.tailwind?.parentLayer === 'utilities'
)
if (layerNodes.utilities && utilityNodes.size === 0 && !hasUtilityVariants) {
log.warn('content-problems', [
'No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration.',
'https://tailwindcss.com/docs/content-configuration',
])
}
// ---
if (env.DEBUG) {
console.log('Potential classes: ', candidates.size)
console.log('Active contexts: ', sharedState.contextSourcesMap.size)
}
// Clear the cache for the changed files
context.changedContent = []
// Cleanup any leftover @layer atrules
root.walkAtRules('layer', (rule) => {
if (Object.keys(layerNodes).includes(rule.params)) {
rule.remove()
}
})
}
}
+720
View File
@@ -0,0 +1,720 @@
import postcss from 'postcss'
import selectorParser from 'postcss-selector-parser'
import parseObjectStyles from '../util/parseObjectStyles'
import isPlainObject from '../util/isPlainObject'
import prefixSelector from '../util/prefixSelector'
import { updateAllClasses } from '../util/pluginUtils'
import log from '../util/log'
import * as sharedState from './sharedState'
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
import { asClass } from '../util/nameClass'
import { normalize } from '../util/dataTypes'
import { isValidVariantFormatString, parseVariant } from './setupContextUtils'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'
import { flagEnabled } from '../featureFlags'
let classNameParser = selectorParser((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
})
function getClassNameFromSelector(selector) {
return classNameParser.transformSync(selector)
}
// Generate match permutations for a class candidate, like:
// ['ring-offset-blue', '100']
// ['ring-offset', 'blue-100']
// ['ring', 'offset-blue-100']
// Example with dynamic classes:
// ['grid-cols', '[[linename],1fr,auto]']
// ['grid', 'cols-[[linename],1fr,auto]']
function* candidatePermutations(candidate) {
let lastIndex = Infinity
while (lastIndex >= 0) {
let dashIdx
if (lastIndex === Infinity && candidate.endsWith(']')) {
let bracketIdx = candidate.indexOf('[')
// If character before `[` isn't a dash or a slash, this isn't a dynamic class
// eg. string[]
dashIdx = ['-', '/'].includes(candidate[bracketIdx - 1]) ? bracketIdx - 1 : -1
} else {
dashIdx = candidate.lastIndexOf('-', lastIndex)
}
if (dashIdx < 0) {
break
}
let prefix = candidate.slice(0, dashIdx)
let modifier = candidate.slice(dashIdx + 1)
yield [prefix, modifier]
lastIndex = dashIdx - 1
}
}
function applyPrefix(matches, context) {
if (matches.length === 0 || context.tailwindConfig.prefix === '') {
return matches
}
for (let match of matches) {
let [meta] = match
if (meta.options.respectPrefix) {
let container = postcss.root({ nodes: [match[1].clone()] })
let classCandidate = match[1].raws.tailwind.classCandidate
container.walkRules((r) => {
// If this is a negative utility with a dash *before* the prefix we
// have to ensure that the generated selector matches the candidate
// Not doing this will cause `-tw-top-1` to generate the class `.tw--top-1`
// The disconnect between candidate <-> class can cause @apply to hard crash.
let shouldPrependNegative = classCandidate.startsWith('-')
r.selector = prefixSelector(
context.tailwindConfig.prefix,
r.selector,
shouldPrependNegative
)
})
match[1] = container.nodes[0]
}
}
return matches
}
function applyImportant(matches, classCandidate) {
if (matches.length === 0) {
return matches
}
let result = []
for (let [meta, rule] of matches) {
let container = postcss.root({ nodes: [rule.clone()] })
container.walkRules((r) => {
r.selector = updateAllClasses(r.selector, (className) => {
if (className === classCandidate) {
return `!${className}`
}
return className
})
r.walkDecls((d) => (d.important = true))
})
result.push([{ ...meta, important: true }, container.nodes[0]])
}
return result
}
// Takes a list of rule tuples and applies a variant like `hover`, sm`,
// whatever to it. We used to do some extra caching here to avoid generating
// a variant of the same rule more than once, but this was never hit because
// we cache at the entire selector level further up the tree.
//
// Technically you can get a cache hit if you have `hover:focus:text-center`
// and `focus:hover:text-center` in the same project, but it doesn't feel
// worth the complexity for that case.
function applyVariant(variant, matches, context) {
if (matches.length === 0) {
return matches
}
let args
// Find partial arbitrary variants
if (variant.endsWith(']') && !variant.startsWith('[')) {
args = variant.slice(variant.lastIndexOf('[') + 1, -1)
variant = variant.slice(0, variant.indexOf(args) - 1 /* - */ - 1 /* [ */)
}
// Register arbitrary variants
if (isArbitraryValue(variant) && !context.variantMap.has(variant)) {
let selector = normalize(variant.slice(1, -1))
if (!isValidVariantFormatString(selector)) {
return []
}
let fn = parseVariant(selector)
let sort = Array.from(context.variantOrder.values()).pop() << 1n
context.variantMap.set(variant, [[sort, fn]])
context.variantOrder.set(variant, sort)
}
if (context.variantMap.has(variant)) {
let variantFunctionTuples = context.variantMap.get(variant).slice()
let result = []
for (let [meta, rule] of matches) {
// Don't generate variants for user css
if (meta.layer === 'user') {
continue
}
let container = postcss.root({ nodes: [rule.clone()] })
for (let [variantSort, variantFunction, containerFromArray] of variantFunctionTuples) {
let clone = containerFromArray ?? container.clone()
let collectedFormats = []
function prepareBackup() {
// Already prepared, chicken out
if (clone.raws.neededBackup) {
return
}
clone.raws.neededBackup = true
clone.walkRules((rule) => (rule.raws.originalSelector = rule.selector))
}
function modifySelectors(modifierFunction) {
prepareBackup()
clone.each((rule) => {
if (rule.type !== 'rule') {
return
}
rule.selectors = rule.selectors.map((selector) => {
return modifierFunction({
get className() {
return getClassNameFromSelector(selector)
},
selector,
})
})
})
return clone
}
let ruleWithVariant = variantFunction({
// Public API
get container() {
prepareBackup()
return clone
},
separator: context.tailwindConfig.separator,
modifySelectors,
// Private API for now
wrap(wrapper) {
let nodes = clone.nodes
clone.removeAll()
wrapper.append(nodes)
clone.append(wrapper)
},
format(selectorFormat) {
collectedFormats.push(selectorFormat)
},
args,
})
// It can happen that a list of format strings is returned from within the function. In that
// case, we have to process them as well. We can use the existing `variantSort`.
if (Array.isArray(ruleWithVariant)) {
for (let [idx, variantFunction] of ruleWithVariant.entries()) {
// This is a little bit scary since we are pushing to an array of items that we are
// currently looping over. However, you can also think of it like a processing queue
// where you keep handling jobs until everything is done and each job can queue more
// jobs if needed.
variantFunctionTuples.push([
// TODO: This could have potential bugs if we shift the sort order from variant A far
// enough into the sort space of variant B. The chances are low, but if this happens
// then this might be the place too look at. One potential solution to this problem is
// reserving additional X places for these 'unknown' variants in between.
variantSort | BigInt(idx << ruleWithVariant.length),
variantFunction,
// If the clone has been modified we have to pass that back
// though so each rule can use the modified container
clone.clone(),
])
}
continue
}
if (typeof ruleWithVariant === 'string') {
collectedFormats.push(ruleWithVariant)
}
if (ruleWithVariant === null) {
continue
}
// We had to backup selectors, therefore we assume that somebody touched
// `container` or `modifySelectors`. Let's see if they did, so that we
// can restore the selectors, and collect the format strings.
if (clone.raws.neededBackup) {
delete clone.raws.neededBackup
clone.walkRules((rule) => {
let before = rule.raws.originalSelector
if (!before) return
delete rule.raws.originalSelector
if (before === rule.selector) return // No mutation happened
let modified = rule.selector
// Rebuild the base selector, this is what plugin authors would do
// as well. E.g.: `${variant}${separator}${className}`.
// However, plugin authors probably also prepend or append certain
// classes, pseudos, ids, ...
let rebuiltBase = selectorParser((selectors) => {
selectors.walkClasses((classNode) => {
classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}`
})
}).processSync(before)
// Now that we know the original selector, the new selector, and
// the rebuild part in between, we can replace the part that plugin
// authors need to rebuild with `&`, and eventually store it in the
// collectedFormats. Similar to what `format('...')` would do.
//
// E.g.:
// variant: foo
// selector: .markdown > p
// modified (by plugin): .foo .foo\\:markdown > p
// rebuiltBase (internal): .foo\\:markdown > p
// format: .foo &
collectedFormats.push(modified.replace(rebuiltBase, '&'))
rule.selector = before
})
}
// This tracks the originating layer for the variant
// For example:
// .sm:underline {} is a variant of something in the utilities layer
// .sm:container {} is a variant of the container component
clone.nodes[0].raws.tailwind = { ...clone.nodes[0].raws.tailwind, parentLayer: meta.layer }
let withOffset = [
{
...meta,
sort: variantSort | meta.sort,
collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats),
isArbitraryVariant: isArbitraryValue(variant),
},
clone.nodes[0],
]
result.push(withOffset)
}
}
return result
}
return []
}
function parseRules(rule, cache, options = {}) {
// PostCSS node
if (!isPlainObject(rule) && !Array.isArray(rule)) {
return [[rule], options]
}
// Tuple
if (Array.isArray(rule)) {
return parseRules(rule[0], cache, rule[1])
}
// Simple object
if (!cache.has(rule)) {
cache.set(rule, parseObjectStyles(rule))
}
return [cache.get(rule), options]
}
const IS_VALID_PROPERTY_NAME = /^[a-z_-]/
function isValidPropName(name) {
return IS_VALID_PROPERTY_NAME.test(name)
}
/**
* @param {string} declaration
* @returns {boolean}
*/
function looksLikeUri(declaration) {
// Quick bailout for obvious non-urls
// This doesn't support schemes that don't use a leading // but that's unlikely to be a problem
if (!declaration.includes('://')) {
return false
}
try {
const url = new URL(declaration)
return url.scheme !== '' && url.host !== ''
} catch (err) {
// Definitely not a valid url
return false
}
}
function isParsableNode(node) {
let isParsable = true
node.walkDecls((decl) => {
if (!isParsableCssValue(decl.name, decl.value)) {
isParsable = false
return false
}
})
return isParsable
}
function isParsableCssValue(property, value) {
// We don't want to to treat [https://example.com] as a custom property
// Even though, according to the CSS grammar, it's a totally valid CSS declaration
// So we short-circuit here by checking if the custom property looks like a url
if (looksLikeUri(`${property}:${value}`)) {
return false
}
try {
postcss.parse(`a{${property}:${value}}`).toResult()
return true
} catch (err) {
return false
}
}
function extractArbitraryProperty(classCandidate, context) {
let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? []
if (value === undefined) {
return null
}
if (!isValidPropName(property)) {
return null
}
if (!isValidArbitraryValue(value)) {
return null
}
let normalized = normalize(value)
if (!isParsableCssValue(property, normalized)) {
return null
}
return [
[
{ sort: context.arbitraryPropertiesSort, layer: 'utilities' },
() => ({
[asClass(classCandidate)]: {
[property]: normalized,
},
}),
],
]
}
function* resolveMatchedPlugins(classCandidate, context) {
if (context.candidateRuleMap.has(classCandidate)) {
yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT']
}
yield* (function* (arbitraryPropertyRule) {
if (arbitraryPropertyRule !== null) {
yield [arbitraryPropertyRule, 'DEFAULT']
}
})(extractArbitraryProperty(classCandidate, context))
let candidatePrefix = classCandidate
let negative = false
const twConfigPrefix = context.tailwindConfig.prefix
const twConfigPrefixLen = twConfigPrefix.length
const hasMatchingPrefix =
candidatePrefix.startsWith(twConfigPrefix) || candidatePrefix.startsWith(`-${twConfigPrefix}`)
if (candidatePrefix[twConfigPrefixLen] === '-' && hasMatchingPrefix) {
negative = true
candidatePrefix = twConfigPrefix + candidatePrefix.slice(twConfigPrefixLen + 1)
}
if (negative && context.candidateRuleMap.has(candidatePrefix)) {
yield [context.candidateRuleMap.get(candidatePrefix), '-DEFAULT']
}
for (let [prefix, modifier] of candidatePermutations(candidatePrefix)) {
if (context.candidateRuleMap.has(prefix)) {
yield [context.candidateRuleMap.get(prefix), negative ? `-${modifier}` : modifier]
}
}
}
function splitWithSeparator(input, separator) {
if (input === sharedState.NOT_ON_DEMAND) {
return [sharedState.NOT_ON_DEMAND]
}
return Array.from(splitAtTopLevelOnly(input, separator))
}
function* recordCandidates(matches, classCandidate) {
for (const match of matches) {
match[1].raws.tailwind = {
...match[1].raws.tailwind,
classCandidate,
preserveSource: match[0].options?.preserveSource ?? false,
}
yield match
}
}
function* resolveMatches(candidate, context, original = candidate) {
let separator = context.tailwindConfig.separator
let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse()
let important = false
if (classCandidate.startsWith('!')) {
important = true
classCandidate = classCandidate.slice(1)
}
if (flagEnabled(context.tailwindConfig, 'variantGrouping')) {
if (classCandidate.startsWith('(') && classCandidate.endsWith(')')) {
let base = variants.slice().reverse().join(separator)
for (let part of splitAtTopLevelOnly(classCandidate.slice(1, -1), ',')) {
yield* resolveMatches(base + separator + part, context, original)
}
}
}
// TODO: Reintroduce this in ways that doesn't break on false positives
// function sortAgainst(toSort, against) {
// return toSort.slice().sort((a, z) => {
// return bigSign(against.get(a)[0] - against.get(z)[0])
// })
// }
// let sorted = sortAgainst(variants, context.variantMap)
// if (sorted.toString() !== variants.toString()) {
// let corrected = sorted.reverse().concat(classCandidate).join(':')
// throw new Error(`Class ${candidate} should be written as ${corrected}`)
// }
for (let matchedPlugins of resolveMatchedPlugins(classCandidate, context)) {
let matches = []
let typesByMatches = new Map()
let [plugins, modifier] = matchedPlugins
let isOnlyPlugin = plugins.length === 1
for (let [sort, plugin] of plugins) {
let matchesPerPlugin = []
if (typeof plugin === 'function') {
for (let ruleSet of [].concat(plugin(modifier, { isOnlyPlugin }))) {
let [rules, options] = parseRules(ruleSet, context.postCssNodeCache)
for (let rule of rules) {
matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule])
}
}
}
// Only process static plugins on exact matches
else if (modifier === 'DEFAULT' || modifier === '-DEFAULT') {
let ruleSet = plugin
let [rules, options] = parseRules(ruleSet, context.postCssNodeCache)
for (let rule of rules) {
matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule])
}
}
if (matchesPerPlugin.length > 0) {
typesByMatches.set(matchesPerPlugin, sort.options?.type)
matches.push(matchesPerPlugin)
}
}
if (isArbitraryValue(modifier)) {
// When generated arbitrary values are ambiguous, we can't know
// which to pick so don't generate any utilities for them
if (matches.length > 1) {
let typesPerPlugin = matches.map((match) => new Set([...(typesByMatches.get(match) ?? [])]))
// Remove duplicates, so that we can detect proper unique types for each plugin.
for (let pluginTypes of typesPerPlugin) {
for (let type of pluginTypes) {
let removeFromOwnGroup = false
for (let otherGroup of typesPerPlugin) {
if (pluginTypes === otherGroup) continue
if (otherGroup.has(type)) {
otherGroup.delete(type)
removeFromOwnGroup = true
}
}
if (removeFromOwnGroup) pluginTypes.delete(type)
}
}
let messages = []
for (let [idx, group] of typesPerPlugin.entries()) {
for (let type of group) {
let rules = matches[idx]
.map(([, rule]) => rule)
.flat()
.map((rule) =>
rule
.toString()
.split('\n')
.slice(1, -1) // Remove selector and closing '}'
.map((line) => line.trim())
.map((x) => ` ${x}`) // Re-indent
.join('\n')
)
.join('\n\n')
messages.push(
` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\``
)
break
}
}
log.warn([
`The class \`${candidate}\` is ambiguous and matches multiple utilities.`,
...messages,
`If this is content and not a class, replace it with \`${candidate
.replace('[', '&lsqb;')
.replace(']', '&rsqb;')}\` to silence this warning.`,
])
continue
}
matches = matches.map((list) => list.filter((match) => isParsableNode(match[1])))
}
matches = matches.flat()
matches = Array.from(recordCandidates(matches, classCandidate))
matches = applyPrefix(matches, context)
if (important) {
matches = applyImportant(matches, classCandidate)
}
for (let variant of variants) {
matches = applyVariant(variant, matches, context)
}
for (let match of matches) {
match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate }
// Apply final format selector
if (match[0].collectedFormats) {
let finalFormat = formatVariantSelector('&', ...match[0].collectedFormats)
let container = postcss.root({ nodes: [match[1].clone()] })
container.walkRules((rule) => {
if (inKeyframes(rule)) return
rule.selector = finalizeSelector(finalFormat, {
selector: rule.selector,
candidate: original,
base: candidate
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
.pop(),
isArbitraryVariant: match[0].isArbitraryVariant,
context,
})
})
match[1] = container.nodes[0]
}
yield match
}
}
}
function inKeyframes(rule) {
return rule.parent && rule.parent.type === 'atrule' && rule.parent.name === 'keyframes'
}
function generateRules(candidates, context) {
let allRules = []
for (let candidate of candidates) {
if (context.notClassCache.has(candidate)) {
continue
}
if (context.classCache.has(candidate)) {
allRules.push(context.classCache.get(candidate))
continue
}
let matches = Array.from(resolveMatches(candidate, context))
if (matches.length === 0) {
context.notClassCache.add(candidate)
continue
}
context.classCache.set(candidate, matches)
allRules.push(matches)
}
// Strategy based on `tailwindConfig.important`
let strategy = ((important) => {
if (important === true) {
return (rule) => {
rule.walkDecls((d) => {
if (d.parent.type === 'rule' && !inKeyframes(d.parent)) {
d.important = true
}
})
}
}
if (typeof important === 'string') {
return (rule) => {
rule.selectors = rule.selectors.map((selector) => {
return `${important} ${selector}`
})
}
}
})(context.tailwindConfig.important)
return allRules.flat(1).map(([{ sort, layer, options }, rule]) => {
if (options.respectImportant) {
if (strategy) {
let container = postcss.root({ nodes: [rule.clone()] })
container.walkRules((r) => {
if (inKeyframes(r)) {
return
}
strategy(r)
})
rule = container.nodes[0]
}
}
return [sort | context.layerOrder[layer], rule]
})
}
function isArbitraryValue(input) {
return input.startsWith('[') && input.endsWith(']')
}
export { resolveMatches, generateRules }
+39
View File
@@ -0,0 +1,39 @@
import fs from 'fs'
import path from 'path'
import resolve from 'resolve'
import detective from 'detective'
function createModule(file) {
const source = fs.readFileSync(file, 'utf-8')
const requires = detective(source)
return { file, requires }
}
export default function getModuleDependencies(entryFile) {
const rootModule = createModule(entryFile)
const modules = [rootModule]
// Iterate over the modules, even when new
// ones are being added
for (const mdl of modules) {
mdl.requires
.filter((dep) => {
// Only track local modules, not node_modules
return dep.startsWith('./') || dep.startsWith('../')
})
.forEach((dep) => {
try {
const basedir = path.dirname(mdl.file)
const depPath = resolve.sync(dep, { basedir })
const depModule = createModule(depPath)
modules.push(depModule)
} catch (_err) {
// eslint-disable-next-line no-empty
}
})
}
return modules
}
+84
View File
@@ -0,0 +1,84 @@
import log from '../util/log'
export default function normalizeTailwindDirectives(root) {
let tailwindDirectives = new Set()
let layerDirectives = new Set()
let applyDirectives = new Set()
root.walkAtRules((atRule) => {
if (atRule.name === 'apply') {
applyDirectives.add(atRule)
}
if (atRule.name === 'import') {
if (atRule.params === '"tailwindcss/base"' || atRule.params === "'tailwindcss/base'") {
atRule.name = 'tailwind'
atRule.params = 'base'
} else if (
atRule.params === '"tailwindcss/components"' ||
atRule.params === "'tailwindcss/components'"
) {
atRule.name = 'tailwind'
atRule.params = 'components'
} else if (
atRule.params === '"tailwindcss/utilities"' ||
atRule.params === "'tailwindcss/utilities'"
) {
atRule.name = 'tailwind'
atRule.params = 'utilities'
} else if (
atRule.params === '"tailwindcss/screens"' ||
atRule.params === "'tailwindcss/screens'" ||
atRule.params === '"tailwindcss/variants"' ||
atRule.params === "'tailwindcss/variants'"
) {
atRule.name = 'tailwind'
atRule.params = 'variants'
}
}
if (atRule.name === 'tailwind') {
if (atRule.params === 'screens') {
atRule.params = 'variants'
}
tailwindDirectives.add(atRule.params)
}
if (['layer', 'responsive', 'variants'].includes(atRule.name)) {
if (['responsive', 'variants'].includes(atRule.name)) {
log.warn(`${atRule.name}-at-rule-deprecated`, [
`The \`@${atRule.name}\` directive has been deprecated in Tailwind CSS v3.0.`,
`Use \`@layer utilities\` or \`@layer components\` instead.`,
'https://tailwindcss.com/docs/upgrade-guide#replace-variants-with-layer',
])
}
layerDirectives.add(atRule)
}
})
if (
!tailwindDirectives.has('base') ||
!tailwindDirectives.has('components') ||
!tailwindDirectives.has('utilities')
) {
for (let rule of layerDirectives) {
if (rule.name === 'layer' && ['base', 'components', 'utilities'].includes(rule.params)) {
if (!tailwindDirectives.has(rule.params)) {
throw rule.error(
`\`@layer ${rule.params}\` is used but no matching \`@tailwind ${rule.params}\` directive is present.`
)
}
} else if (rule.name === 'responsive') {
if (!tailwindDirectives.has('utilities')) {
throw rule.error('`@responsive` is used but `@tailwind utilities` is missing.')
}
} else if (rule.name === 'variants') {
if (!tailwindDirectives.has('utilities')) {
throw rule.error('`@variants` is used but `@tailwind utilities` is missing.')
}
}
}
}
return { tailwindDirectives, applyDirectives }
}
+52
View File
@@ -0,0 +1,52 @@
function partitionRules(root) {
if (!root.walkAtRules) return
let applyParents = new Set()
root.walkAtRules('apply', (rule) => {
applyParents.add(rule.parent)
})
if (applyParents.size === 0) {
return
}
for (let rule of applyParents) {
let nodeGroups = []
let lastGroup = []
for (let node of rule.nodes) {
if (node.type === 'atrule' && node.name === 'apply') {
if (lastGroup.length > 0) {
nodeGroups.push(lastGroup)
lastGroup = []
}
nodeGroups.push([node])
} else {
lastGroup.push(node)
}
}
if (lastGroup.length > 0) {
nodeGroups.push(lastGroup)
}
if (nodeGroups.length === 1) {
continue
}
for (let group of [...nodeGroups].reverse()) {
let clone = rule.clone({ nodes: [] })
clone.append(group)
rule.after(clone)
}
rule.remove()
}
}
export default function expandApplyAtRules() {
return (root) => {
partitionRules(root)
}
}
+74
View File
@@ -0,0 +1,74 @@
const REGEX_SPECIAL = /[\\^$.*+?()[\]{}|]/g
const REGEX_HAS_SPECIAL = RegExp(REGEX_SPECIAL.source)
/**
* @param {string|RegExp|Array<string|RegExp>} source
*/
function toSource(source) {
source = Array.isArray(source) ? source : [source]
source = source.map((item) => (item instanceof RegExp ? item.source : item))
return source.join('')
}
/**
* @param {string|RegExp|Array<string|RegExp>} source
*/
export function pattern(source) {
return new RegExp(toSource(source), 'g')
}
/**
* @param {string|RegExp|Array<string|RegExp>} source
*/
export function withoutCapturing(source) {
return new RegExp(`(?:${toSource(source)})`, 'g')
}
/**
* @param {Array<string|RegExp>} sources
*/
export function any(sources) {
return `(?:${sources.map(toSource).join('|')})`
}
/**
* @param {string|RegExp} source
*/
export function optional(source) {
return `(?:${toSource(source)})?`
}
/**
* @param {string|RegExp|Array<string|RegExp>} source
*/
export function zeroOrMore(source) {
return `(?:${toSource(source)})*`
}
/**
* Generate a RegExp that matches balanced brackets for a given depth
* We have to specify a depth because JS doesn't support recursive groups using ?R
*
* Based on https://stackoverflow.com/questions/17759004/how-to-match-string-within-parentheses-nested-in-java/17759264#17759264
*
* @param {string|RegExp|Array<string|RegExp>} source
*/
export function nestedBrackets(open, close, depth = 1) {
return withoutCapturing([
escape(open),
/[^\s]*/,
depth === 1
? `[^${escape(open)}${escape(close)}\s]*`
: any([`[^${escape(open)}${escape(close)}\s]*`, nestedBrackets(open, close, depth - 1)]),
/[^\s]*/,
escape(close),
])
}
export function escape(string) {
return string && REGEX_HAS_SPECIAL.test(string)
? string.replace(REGEX_SPECIAL, '\\$&')
: string || ''
}
+163
View File
@@ -0,0 +1,163 @@
import postcss from 'postcss'
import selectorParser from 'postcss-selector-parser'
import { flagEnabled } from '../featureFlags'
let getNode = {
id(node) {
return selectorParser.attribute({
attribute: 'id',
operator: '=',
value: node.value,
quoteMark: '"',
})
},
}
function minimumImpactSelector(nodes) {
let rest = nodes
.filter((node) => {
// Keep non-pseudo nodes
if (node.type !== 'pseudo') return true
// Keep pseudo nodes that have subnodes
// E.g.: `:not()` contains subnodes inside the parentheses
if (node.nodes.length > 0) return true
// Keep pseudo `elements`
// This implicitly means that we ignore pseudo `classes`
return (
node.value.startsWith('::') ||
[':before', ':after', ':first-line', ':first-letter'].includes(node.value)
)
})
.reverse()
let searchFor = new Set(['tag', 'class', 'id', 'attribute'])
let splitPointIdx = rest.findIndex((n) => searchFor.has(n.type))
if (splitPointIdx === -1) return rest.reverse().join('').trim()
let node = rest[splitPointIdx]
let bestNode = getNode[node.type] ? getNode[node.type](node) : node
rest = rest.slice(0, splitPointIdx)
let combinatorIdx = rest.findIndex((n) => n.type === 'combinator' && n.value === '>')
if (combinatorIdx !== -1) {
rest.splice(0, combinatorIdx)
rest.unshift(selectorParser.universal())
}
return [bestNode, ...rest.reverse()].join('').trim()
}
export let elementSelectorParser = selectorParser((selectors) => {
return selectors.map((s) => {
let nodes = s.split((n) => n.type === 'combinator' && n.value === ' ').pop()
return minimumImpactSelector(nodes)
})
})
let cache = new Map()
function extractElementSelector(selector) {
if (!cache.has(selector)) {
cache.set(selector, elementSelectorParser.transformSync(selector))
}
return cache.get(selector)
}
export default function resolveDefaultsAtRules({ tailwindConfig }) {
return (root) => {
let variableNodeMap = new Map()
/** @type {Set<import('postcss').AtRule>} */
let universals = new Set()
root.walkAtRules('defaults', (rule) => {
if (rule.nodes && rule.nodes.length > 0) {
universals.add(rule)
return
}
let variable = rule.params
if (!variableNodeMap.has(variable)) {
variableNodeMap.set(variable, new Set())
}
variableNodeMap.get(variable).add(rule.parent)
rule.remove()
})
if (flagEnabled(tailwindConfig, 'optimizeUniversalDefaults')) {
for (let universal of universals) {
/** @type {Map<string, Set<string>>} */
let selectorGroups = new Map()
let rules = variableNodeMap.get(universal.params) ?? []
for (let rule of rules) {
for (let selector of extractElementSelector(rule.selector)) {
// If selector contains a vendor prefix after a pseudo element or class,
// we consider them separately because merging the declarations into
// a single rule will cause browsers that do not understand the
// vendor prefix to throw out the whole rule
let selectorGroupName =
selector.includes(':-') || selector.includes('::-') ? selector : '__DEFAULT__'
let selectors = selectorGroups.get(selectorGroupName) ?? new Set()
selectorGroups.set(selectorGroupName, selectors)
selectors.add(selector)
}
}
if (flagEnabled(tailwindConfig, 'optimizeUniversalDefaults')) {
if (selectorGroups.size === 0) {
universal.remove()
continue
}
for (let [, selectors] of selectorGroups) {
let universalRule = postcss.rule({
source: universal.source,
})
universalRule.selectors = [...selectors]
universalRule.append(universal.nodes.map((node) => node.clone()))
universal.before(universalRule)
}
}
universal.remove()
}
} else if (universals.size) {
let universalRule = postcss.rule({
selectors: ['*', '::before', '::after'],
})
for (let universal of universals) {
universalRule.append(universal.nodes)
if (!universalRule.parent) {
universal.before(universalRule)
}
if (!universalRule.source) {
universalRule.source = universal.source
}
universal.remove()
}
let backdropRule = universalRule.clone({
selectors: ['::backdrop'],
})
universalRule.after(backdropRule)
}
}
}
+979
View File
@@ -0,0 +1,979 @@
import fs from 'fs'
import url from 'url'
import postcss from 'postcss'
import dlv from 'dlv'
import selectorParser from 'postcss-selector-parser'
import { flagEnabled } from '../featureFlags.js'
import transformThemeValue from '../util/transformThemeValue'
import parseObjectStyles from '../util/parseObjectStyles'
import prefixSelector from '../util/prefixSelector'
import isPlainObject from '../util/isPlainObject'
import escapeClassName from '../util/escapeClassName'
import nameClass, { formatClass } from '../util/nameClass'
import { coerceValue } from '../util/pluginUtils'
import bigSign from '../util/bigSign'
import { variantPlugins, corePlugins } from '../corePlugins'
import * as sharedState from './sharedState'
import { env } from './sharedState'
import { toPath } from '../util/toPath'
import log from '../util/log'
import negateValue from '../util/negateValue'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { generateRules } from './generateRules'
import { hasContentChanged } from './cacheInvalidation.js'
let MATCH_VARIANT = Symbol()
function prefix(context, selector) {
let prefix = context.tailwindConfig.prefix
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
}
function parseVariantFormatString(input) {
if (input.includes('{')) {
if (!isBalanced(input)) throw new Error(`Your { and } are unbalanced.`)
return input
.split(/{(.*)}/gim)
.flatMap((line) => parseVariantFormatString(line))
.filter(Boolean)
}
return [input.trim()]
}
function isBalanced(input) {
let count = 0
for (let char of input) {
if (char === '{') {
count++
} else if (char === '}') {
if (--count < 0) {
return false // unbalanced
}
}
}
return count === 0
}
function insertInto(list, value, { before = [] } = {}) {
before = [].concat(before)
if (before.length <= 0) {
list.push(value)
return
}
let idx = list.length - 1
for (let other of before) {
let iidx = list.indexOf(other)
if (iidx === -1) continue
idx = Math.min(idx, iidx)
}
list.splice(idx, 0, value)
}
function parseStyles(styles) {
if (!Array.isArray(styles)) {
return parseStyles([styles])
}
return styles.flatMap((style) => {
let isNode = !Array.isArray(style) && !isPlainObject(style)
return isNode ? style : parseObjectStyles(style)
})
}
function getClasses(selector, mutate) {
let parser = selectorParser((selectors) => {
let allClasses = []
if (mutate) {
mutate(selectors)
}
selectors.walkClasses((classNode) => {
allClasses.push(classNode.value)
})
return allClasses
})
return parser.transformSync(selector)
}
function extractCandidates(node, state = { containsNonOnDemandable: false }, depth = 0) {
let classes = []
// Handle normal rules
if (node.type === 'rule') {
// Ignore everything inside a :not(...). This allows you to write code like
// `div:not(.foo)`. If `.foo` is never found in your code, then we used to
// not generated it. But now we will ignore everything inside a `:not`, so
// that it still gets generated.
function ignoreNot(selectors) {
selectors.walkPseudos((pseudo) => {
if (pseudo.value === ':not') {
pseudo.remove()
}
})
}
for (let selector of node.selectors) {
let classCandidates = getClasses(selector, ignoreNot)
// At least one of the selectors contains non-"on-demandable" candidates.
if (classCandidates.length === 0) {
state.containsNonOnDemandable = true
}
for (let classCandidate of classCandidates) {
classes.push(classCandidate)
}
}
}
// Handle at-rules (which contains nested rules)
else if (node.type === 'atrule') {
node.walkRules((rule) => {
for (let classCandidate of rule.selectors.flatMap((selector) => getClasses(selector))) {
classes.push(classCandidate)
}
})
}
if (depth === 0) {
return [state.containsNonOnDemandable || classes.length === 0, classes]
}
return classes
}
function withIdentifiers(styles) {
return parseStyles(styles).flatMap((node) => {
let nodeMap = new Map()
let [containsNonOnDemandableSelectors, candidates] = extractCandidates(node)
// If this isn't "on-demandable", assign it a universal candidate to always include it.
if (containsNonOnDemandableSelectors) {
candidates.unshift(sharedState.NOT_ON_DEMAND)
}
// However, it could be that it also contains "on-demandable" candidates.
// E.g.: `span, .foo {}`, in that case it should still be possible to use
// `@apply foo` for example.
return candidates.map((c) => {
if (!nodeMap.has(node)) {
nodeMap.set(node, node)
}
return [c, nodeMap.get(node)]
})
})
}
export function isValidVariantFormatString(format) {
return format.startsWith('@') || format.includes('&')
}
export function parseVariant(variant) {
variant = variant
.replace(/\n+/g, '')
.replace(/\s{1,}/g, ' ')
.trim()
let fns = parseVariantFormatString(variant)
.map((str) => {
if (!str.startsWith('@')) {
return ({ format }) => format(str)
}
let [, name, params] = /@(.*?)( .+|[({].*)/g.exec(str)
return ({ wrap }) => wrap(postcss.atRule({ name, params: params.trim() }))
})
.reverse()
return (api) => {
for (let fn of fns) {
fn(api)
}
}
}
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
function getConfigValue(path, defaultValue) {
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
}
function applyConfiguredPrefix(selector) {
return prefixSelector(tailwindConfig.prefix, selector)
}
function prefixIdentifier(identifier, options) {
if (identifier === sharedState.NOT_ON_DEMAND) {
return sharedState.NOT_ON_DEMAND
}
if (!options.respectPrefix) {
return identifier
}
return context.tailwindConfig.prefix + identifier
}
function resolveThemeValue(path, defaultValue, opts = {}) {
const [pathRoot, ...subPaths] = toPath(path)
const value = getConfigValue(['theme', pathRoot, ...subPaths], defaultValue)
return transformThemeValue(pathRoot)(value, opts)
}
const theme = Object.assign(
(path, defaultValue = undefined) => resolveThemeValue(path, defaultValue),
{
withAlpha: (path, opacityValue) => resolveThemeValue(path, undefined, { opacityValue }),
}
)
let api = {
postcss,
prefix: applyConfiguredPrefix,
e: escapeClassName,
config: getConfigValue,
theme,
corePlugins: (path) => {
if (Array.isArray(tailwindConfig.corePlugins)) {
return tailwindConfig.corePlugins.includes(path)
}
return getConfigValue(['corePlugins', path], true)
},
variants: () => {
// Preserved for backwards compatibility but not used in v3.0+
return []
},
addBase(base) {
for (let [identifier, rule] of withIdentifiers(base)) {
let prefixedIdentifier = prefixIdentifier(identifier, {})
let offset = offsets.base++
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offset, layer: 'base' }, rule])
}
},
/**
* @param {string} group
* @param {Record<string, string | string[]>} declarations
*/
addDefaults(group, declarations) {
const groups = {
[`@defaults ${group}`]: declarations,
}
for (let [identifier, rule] of withIdentifiers(groups)) {
let prefixedIdentifier = prefixIdentifier(identifier, {})
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offsets.base++, layer: 'defaults' }, rule])
}
},
addComponents(components, options) {
let defaultOptions = {
preserveSource: false,
respectPrefix: true,
respectImportant: false,
}
options = Object.assign({}, defaultOptions, Array.isArray(options) ? {} : options)
for (let [identifier, rule] of withIdentifiers(components)) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
classList.add(prefixedIdentifier)
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offsets.components++, layer: 'components', options }, rule])
}
},
addUtilities(utilities, options) {
let defaultOptions = {
preserveSource: false,
respectPrefix: true,
respectImportant: true,
}
options = Object.assign({}, defaultOptions, Array.isArray(options) ? {} : options)
for (let [identifier, rule] of withIdentifiers(utilities)) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
classList.add(prefixedIdentifier)
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offsets.utilities++, layer: 'utilities', options }, rule])
}
},
matchUtilities: function (utilities, options) {
let defaultOptions = {
respectPrefix: true,
respectImportant: true,
}
options = { ...defaultOptions, ...options }
let offset = offsets.utilities++
for (let identifier in utilities) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let rule = utilities[identifier]
classList.add([prefixedIdentifier, options])
function wrapped(modifier, { isOnlyPlugin }) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
if (value === undefined) {
return []
}
if (!type.includes(coercedType) && !isOnlyPlugin) {
return []
}
if (!isValidArbitraryValue(value)) {
return []
}
let ruleSets = []
.concat(rule(value))
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
}))
return ruleSets
}
let withOffsets = [{ sort: offset, layer: 'utilities', options }, wrapped]
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap.get(prefixedIdentifier).push(withOffsets)
}
},
matchComponents: function (components, options) {
let defaultOptions = {
respectPrefix: true,
respectImportant: false,
}
options = { ...defaultOptions, ...options }
let offset = offsets.components++
for (let identifier in components) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
let rule = components[identifier]
classList.add([prefixedIdentifier, options])
function wrapped(modifier, { isOnlyPlugin }) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
if (value === undefined) {
return []
}
if (!type.includes(coercedType)) {
if (isOnlyPlugin) {
log.warn([
`Unnecessary typehint \`${coercedType}\` in \`${identifier}-${modifier}\`.`,
`You can safely update it to \`${identifier}-${modifier.replace(
coercedType + ':',
''
)}\`.`,
])
} else {
return []
}
}
if (!isValidArbitraryValue(value)) {
return []
}
let ruleSets = []
.concat(rule(value))
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
}))
return ruleSets
}
let withOffsets = [{ sort: offset, layer: 'components', options }, wrapped]
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
}
context.candidateRuleMap.get(prefixedIdentifier).push(withOffsets)
}
},
addVariant(variantName, variantFunctions, options = {}) {
variantFunctions = [].concat(variantFunctions).map((variantFunction) => {
if (typeof variantFunction !== 'string') {
// Safelist public API functions
return (api) => {
let { args, modifySelectors, container, separator, wrap, format } = api
let result = variantFunction(
Object.assign(
{ modifySelectors, container, separator },
variantFunction[MATCH_VARIANT] && { args, wrap, format }
)
)
if (typeof result === 'string' && !isValidVariantFormatString(result)) {
throw new Error(
`Your custom variant \`${variantName}\` has an invalid format string. Make sure it's an at-rule or contains a \`&\` placeholder.`
)
}
if (Array.isArray(result)) {
return result
.filter((variant) => typeof variant === 'string')
.map((variant) => parseVariant(variant))
}
// result may be undefined with legacy variants that use APIs like `modifySelectors`
// result may also be a postcss node if someone was returning the result from `modifySelectors`
return result && typeof result === 'string' && parseVariant(result)(api)
}
}
if (!isValidVariantFormatString(variantFunction)) {
throw new Error(
`Your custom variant \`${variantName}\` has an invalid format string. Make sure it's an at-rule or contains a \`&\` placeholder.`
)
}
return parseVariant(variantFunction)
})
insertInto(variantList, variantName, options)
variantMap.set(variantName, variantFunctions)
},
}
if (flagEnabled(tailwindConfig, 'matchVariant')) {
api.matchVariant = function (variants, options) {
for (let variant in variants) {
for (let [k, v] of Object.entries(options?.values ?? {})) {
api.addVariant(`${variant}-${k}`, variants[variant](v))
}
api.addVariant(
variant,
Object.assign(({ args }) => variants[variant](args), { [MATCH_VARIANT]: true }),
options
)
}
}
}
return api
}
let fileModifiedMapCache = new WeakMap()
export function getFileModifiedMap(context) {
if (!fileModifiedMapCache.has(context)) {
fileModifiedMapCache.set(context, new Map())
}
return fileModifiedMapCache.get(context)
}
function trackModified(files, fileModifiedMap) {
let changed = false
for (let file of files) {
if (!file) continue
let parsed = url.parse(file)
let pathname = parsed.hash ? parsed.href.replace(parsed.hash, '') : parsed.href
pathname = parsed.search ? pathname.replace(parsed.search, '') : pathname
let newModified = fs.statSync(decodeURIComponent(pathname), { throwIfNoEntry: false })?.mtimeMs
if (!newModified) {
// It could happen that a file is passed in that doesn't exist. E.g.:
// postcss-cli will provide you a fake path when reading from stdin. This
// path then looks like /path-to-your-project/stdin In that case we just
// want to ignore it and don't track changes at all.
continue
}
if (!fileModifiedMap.has(file) || newModified > fileModifiedMap.get(file)) {
changed = true
}
fileModifiedMap.set(file, newModified)
}
return changed
}
function extractVariantAtRules(node) {
node.walkAtRules((atRule) => {
if (['responsive', 'variants'].includes(atRule.name)) {
extractVariantAtRules(atRule)
atRule.before(atRule.nodes)
atRule.remove()
}
})
}
function collectLayerPlugins(root) {
let layerPlugins = []
root.each((node) => {
if (node.type === 'atrule' && ['responsive', 'variants'].includes(node.name)) {
node.name = 'layer'
node.params = 'utilities'
}
})
// Walk @layer rules and treat them like plugins
root.walkAtRules('layer', (layerRule) => {
extractVariantAtRules(layerRule)
if (layerRule.params === 'base') {
for (let node of layerRule.nodes) {
layerPlugins.push(function ({ addBase }) {
addBase(node, { respectPrefix: false })
})
}
layerRule.remove()
} else if (layerRule.params === 'components') {
for (let node of layerRule.nodes) {
layerPlugins.push(function ({ addComponents }) {
addComponents(node, { respectPrefix: false, preserveSource: true })
})
}
layerRule.remove()
} else if (layerRule.params === 'utilities') {
for (let node of layerRule.nodes) {
layerPlugins.push(function ({ addUtilities }) {
addUtilities(node, { respectPrefix: false, preserveSource: true })
})
}
layerRule.remove()
}
})
return layerPlugins
}
function resolvePlugins(context, root) {
let corePluginList = Object.entries({ ...variantPlugins, ...corePlugins })
.map(([name, plugin]) => {
if (!context.tailwindConfig.corePlugins.includes(name)) {
return null
}
return plugin
})
.filter(Boolean)
let userPlugins = context.tailwindConfig.plugins.map((plugin) => {
if (plugin.__isOptionsFunction) {
plugin = plugin()
}
return typeof plugin === 'function' ? plugin : plugin.handler
})
let layerPlugins = collectLayerPlugins(root)
// TODO: This is a workaround for backwards compatibility, since custom variants
// were historically sorted before screen/stackable variants.
let beforeVariants = [
variantPlugins['pseudoElementVariants'],
variantPlugins['pseudoClassVariants'],
]
let afterVariants = [
variantPlugins['directionVariants'],
variantPlugins['reducedMotionVariants'],
variantPlugins['prefersContrastVariants'],
variantPlugins['darkVariants'],
variantPlugins['printVariant'],
variantPlugins['screenVariants'],
variantPlugins['orientationVariants'],
]
return [...corePluginList, ...beforeVariants, ...userPlugins, ...afterVariants, ...layerPlugins]
}
function registerPlugins(plugins, context) {
let variantList = []
let variantMap = new Map()
let offsets = {
defaults: 0n,
base: 0n,
components: 0n,
utilities: 0n,
user: 0n,
}
let classList = new Set()
let pluginApi = buildPluginApi(context.tailwindConfig, context, {
variantList,
variantMap,
offsets,
classList,
})
for (let plugin of plugins) {
if (Array.isArray(plugin)) {
for (let pluginItem of plugin) {
pluginItem(pluginApi)
}
} else {
plugin?.(pluginApi)
}
}
let highestOffset = ((args) => args.reduce((m, e) => (e > m ? e : m)))([
offsets.base,
offsets.defaults,
offsets.components,
offsets.utilities,
offsets.user,
])
let reservedBits = BigInt(highestOffset.toString(2).length)
// A number one less than the top range of the highest offset area
// so arbitrary properties are always sorted at the end.
context.arbitraryPropertiesSort = ((1n << reservedBits) << 0n) - 1n
context.layerOrder = {
defaults: (1n << reservedBits) << 0n,
base: (1n << reservedBits) << 1n,
components: (1n << reservedBits) << 2n,
utilities: (1n << reservedBits) << 3n,
user: (1n << reservedBits) << 4n,
}
reservedBits += 5n
let offset = 0
context.variantOrder = new Map(
variantList
.map((variant, i) => {
let variantFunctions = variantMap.get(variant).length
let bits = (1n << BigInt(i + offset)) << reservedBits
offset += variantFunctions - 1
return [variant, bits]
})
.sort(([, a], [, z]) => bigSign(a - z))
)
context.minimumScreen = [...context.variantOrder.values()].shift()
// Build variantMap
for (let [variantName, variantFunctions] of variantMap.entries()) {
let sort = context.variantOrder.get(variantName)
context.variantMap.set(
variantName,
variantFunctions.map((variantFunction, idx) => [sort << BigInt(idx), variantFunction])
)
}
let safelist = (context.tailwindConfig.safelist ?? []).filter(Boolean)
if (safelist.length > 0) {
let checks = []
for (let value of safelist) {
if (typeof value === 'string') {
context.changedContent.push({ content: value, extension: 'html' })
continue
}
if (value instanceof RegExp) {
log.warn('root-regex', [
'Regular expressions in `safelist` work differently in Tailwind CSS v3.0.',
'Update your `safelist` configuration to eliminate this warning.',
'https://tailwindcss.com/docs/content-configuration#safelisting-classes',
])
continue
}
checks.push(value)
}
if (checks.length > 0) {
let patternMatchingCount = new Map()
let prefixLength = context.tailwindConfig.prefix.length
for (let util of classList) {
let utils = Array.isArray(util)
? (() => {
let [utilName, options] = util
let values = Object.keys(options?.values ?? {})
let classes = values.map((value) => formatClass(utilName, value))
if (options?.supportsNegativeValues) {
// This is the normal negated version
// e.g. `-inset-1` or `-tw-inset-1`
classes = [...classes, ...classes.map((cls) => '-' + cls)]
// This is the negated version *after* the prefix
// e.g. `tw--inset-1`
// The prefix is already attached to util name
// So we add the negative after the prefix
classes = [
...classes,
...classes.map(
(cls) => cls.slice(0, prefixLength) + '-' + cls.slice(prefixLength)
),
]
}
if ([].concat(options?.type).includes('color')) {
classes = [
...classes,
...classes.flatMap((cls) =>
Object.keys(context.tailwindConfig.theme.opacity).map(
(opacity) => `${cls}/${opacity}`
)
),
]
}
return classes
})()
: [util]
for (let util of utils) {
for (let { pattern, variants = [] } of checks) {
// RegExp with the /g flag are stateful, so let's reset the last
// index pointer to reset the state.
pattern.lastIndex = 0
if (!patternMatchingCount.has(pattern)) {
patternMatchingCount.set(pattern, 0)
}
if (!pattern.test(util)) continue
patternMatchingCount.set(pattern, patternMatchingCount.get(pattern) + 1)
context.changedContent.push({ content: util, extension: 'html' })
for (let variant of variants) {
context.changedContent.push({
content: variant + context.tailwindConfig.separator + util,
extension: 'html',
})
}
}
}
}
for (let [regex, count] of patternMatchingCount.entries()) {
if (count !== 0) continue
log.warn([
`The safelist pattern \`${regex}\` doesn't match any Tailwind CSS classes.`,
'Fix this pattern or remove it from your `safelist` configuration.',
'https://tailwindcss.com/docs/content-configuration#safelisting-classes',
])
}
}
}
// A list of utilities that are used by certain Tailwind CSS utilities but
// that don't exist on their own. This will result in them "not existing" and
// sorting could be weird since you still require them in order to make the
// host utitlies work properly. (Thanks Biology)
let parasiteUtilities = new Set([prefix(context, 'group'), prefix(context, 'peer')])
context.getClassOrder = function getClassOrder(classes) {
let sortedClassNames = new Map()
for (let [sort, rule] of generateRules(new Set(classes), context)) {
if (sortedClassNames.has(rule.raws.tailwind.candidate)) continue
sortedClassNames.set(rule.raws.tailwind.candidate, sort)
}
return classes.map((className) => {
let order = sortedClassNames.get(className) ?? null
if (order === null && parasiteUtilities.has(className)) {
// This will make sure that it is at the very beginning of the
// `components` layer which technically means 'before any
// components'.
order = context.layerOrder.components
}
return [className, order]
})
}
// Generate a list of strings for autocompletion purposes, e.g.
// ['uppercase', 'lowercase', ...]
context.getClassList = function getClassList() {
let output = []
for (let util of classList) {
if (Array.isArray(util)) {
let [utilName, options] = util
let negativeClasses = []
for (let [key, value] of Object.entries(options?.values ?? {})) {
output.push(formatClass(utilName, key))
if (options?.supportsNegativeValues && negateValue(value)) {
negativeClasses.push(formatClass(utilName, `-${key}`))
}
}
output.push(...negativeClasses)
} else {
output.push(util)
}
}
return output
}
}
export function createContext(tailwindConfig, changedContent = [], root = postcss.root()) {
let context = {
disposables: [],
ruleCache: new Set(),
classCache: new Map(),
applyClassCache: new Map(),
notClassCache: new Set(),
postCssNodeCache: new Map(),
candidateRuleMap: new Map(),
tailwindConfig,
changedContent: changedContent,
variantMap: new Map(),
stylesheetCache: null,
}
let resolvedPlugins = resolvePlugins(context, root)
registerPlugins(resolvedPlugins, context)
return context
}
let contextMap = sharedState.contextMap
let configContextMap = sharedState.configContextMap
let contextSourcesMap = sharedState.contextSourcesMap
export function getContext(
root,
result,
tailwindConfig,
userConfigPath,
tailwindConfigHash,
contextDependencies
) {
let sourcePath = result.opts.from
let isConfigFile = userConfigPath !== null
env.DEBUG && console.log('Source path:', sourcePath)
let existingContext
if (isConfigFile && contextMap.has(sourcePath)) {
existingContext = contextMap.get(sourcePath)
} else if (configContextMap.has(tailwindConfigHash)) {
let context = configContextMap.get(tailwindConfigHash)
contextSourcesMap.get(context).add(sourcePath)
contextMap.set(sourcePath, context)
existingContext = context
}
let cssDidChange = hasContentChanged(sourcePath, root)
// If there's already a context in the cache and we don't need to
// reset the context, return the cached context.
if (existingContext) {
let contextDependenciesChanged = trackModified(
[...contextDependencies],
getFileModifiedMap(existingContext)
)
if (!contextDependenciesChanged && !cssDidChange) {
return [existingContext, false]
}
}
// If this source is in the context map, get the old context.
// Remove this source from the context sources for the old context,
// and clean up that context if no one else is using it. This can be
// called by many processes in rapid succession, so we check for presence
// first because the first process to run this code will wipe it out first.
if (contextMap.has(sourcePath)) {
let oldContext = contextMap.get(sourcePath)
if (contextSourcesMap.has(oldContext)) {
contextSourcesMap.get(oldContext).delete(sourcePath)
if (contextSourcesMap.get(oldContext).size === 0) {
contextSourcesMap.delete(oldContext)
for (let [tailwindConfigHash, context] of configContextMap) {
if (context === oldContext) {
configContextMap.delete(tailwindConfigHash)
}
}
for (let disposable of oldContext.disposables.splice(0)) {
disposable(oldContext)
}
}
}
}
env.DEBUG && console.log('Setting up new context...')
let context = createContext(tailwindConfig, [], root)
trackModified([...contextDependencies], getFileModifiedMap(context))
// ---
// Update all context tracking state
configContextMap.set(tailwindConfigHash, context)
contextMap.set(sourcePath, context)
if (!contextSourcesMap.has(context)) {
contextSourcesMap.set(context, new Set())
}
contextSourcesMap.get(context).add(sourcePath)
return [context, true]
}
+187
View File
@@ -0,0 +1,187 @@
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'
let configPathCache = new LRU({ maxSize: 100 })
let candidateFilesCache = new WeakMap()
function getCandidateFiles(context, tailwindConfig) {
if (candidateFilesCache.has(context)) {
return candidateFilesCache.get(context)
}
let candidateFiles = tailwindConfig.content.files
.filter((item) => typeof item === 'string')
.map((contentPath) => normalizePath(contentPath))
return candidateFilesCache.set(context, candidateFiles).get(context)
}
// Get the config object based on a path
function getTailwindConfig(configOrPath) {
let userConfigPath = resolveConfigPath(configOrPath)
if (userConfigPath !== null) {
let [prevConfig, prevConfigHash, prevDeps, prevModified] =
configPathCache.get(userConfigPath) || []
let newDeps = getModuleDependencies(userConfigPath).map((dep) => dep.file)
let modified = false
let newModified = new Map()
for (let file of newDeps) {
let time = fs.statSync(file).mtimeMs
newModified.set(file, time)
if (!prevModified || !prevModified.has(file) || time > prevModified.get(file)) {
modified = true
}
}
// It hasn't changed (based on timestamps)
if (!modified) {
return [prevConfig, userConfigPath, prevConfigHash, prevDeps]
}
// It has changed (based on timestamps), or first run
for (let file of newDeps) {
delete require.cache[file]
}
let newConfig = resolveConfig(require(userConfigPath))
newConfig = validateConfig(newConfig)
let newHash = hash(newConfig)
configPathCache.set(userConfigPath, [newConfig, newHash, newDeps, newModified])
return [newConfig, userConfigPath, newHash, newDeps]
}
// It's a plain object, not a path
let newConfig = resolveConfig(
configOrPath.config === undefined ? configOrPath : configOrPath.config
)
newConfig = validateConfig(newConfig)
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)
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
// source path), or set up a new one (including setting up watchers and registering
// plugins) then return it
export default function setupTrackingContext(configOrPath) {
return ({ tailwindDirectives, registerDependency }) => {
return (root, result) => {
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
getTailwindConfig(configOrPath)
let contextDependencies = new Set(configDependencies)
// If there are no @tailwind or @apply rules, we don't consider this CSS
// file or its dependencies to be dependencies of the context. Can reuse
// the context even if they change. We may want to think about `@layer`
// being part of this trigger too, but it's tough because it's impossible
// for a layer in one file to end up in the actual @tailwind rule in
// another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
// Add current css file as a context dependencies.
contextDependencies.add(result.opts.from)
// Add all css @import dependencies as context dependencies.
for (let message of result.messages) {
if (message.type === 'dependency') {
contextDependencies.add(message.file)
}
}
}
let [context] = getContext(
root,
result,
tailwindConfig,
userConfigPath,
tailwindConfigHash,
contextDependencies
)
let candidateFiles = getCandidateFiles(context, tailwindConfig)
// If there are no @tailwind or @apply rules, we don't consider this CSS file or it's
// dependencies to be dependencies of the context. Can reuse the context even if they change.
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
let fileModifiedMap = getFileModifiedMap(context)
// Add template paths as postcss dependencies.
for (let fileOrGlob of candidateFiles) {
let dependency = parseDependency(fileOrGlob)
if (dependency) {
registerDependency(dependency)
}
}
for (let changedContent of resolvedChangedContent(
context,
candidateFiles,
fileModifiedMap
)) {
context.changedContent.push(changedContent)
}
}
for (let file of configDependencies) {
registerDependency({ type: 'dependency', file })
}
return context
}
}
}
+48
View File
@@ -0,0 +1,48 @@
export const env = {
NODE_ENV: process.env.NODE_ENV,
DEBUG: resolveDebug(process.env.DEBUG),
}
export const contextMap = new Map()
export const configContextMap = new Map()
export const contextSourcesMap = new Map()
export const sourceHashMap = new Map()
export const NOT_ON_DEMAND = new String('*')
export function resolveDebug(debug) {
if (debug === undefined) {
return false
}
// Environment variables are strings, so convert to boolean
if (debug === 'true' || debug === '1') {
return true
}
if (debug === 'false' || debug === '0') {
return false
}
// Keep the debug convention into account:
// DEBUG=* -> This enables all debug modes
// DEBUG=projectA,projectB,projectC -> This enables debug for projectA, projectB and projectC
// DEBUG=projectA:* -> This enables all debug modes for projectA (if you have sub-types)
// DEBUG=projectA,-projectB -> This enables debug for projectA and explicitly disables it for projectB
if (debug === '*') {
return true
}
let debuggers = debug.split(',').map((d) => d.split(':')[0])
// Ignoring tailwindcss
if (debuggers.includes('-tailwindcss')) {
return false
}
// Including tailwindcss
if (debuggers.includes('tailwindcss')) {
return true
}
return false
}
+19
View File
@@ -0,0 +1,19 @@
import { normalizeScreens } from '../util/normalizeScreens'
import buildMediaQuery from '../util/buildMediaQuery'
export default function ({ tailwindConfig: { theme } }) {
return function (css) {
css.walkAtRules('screen', (atRule) => {
let screen = atRule.params
let screens = normalizeScreens(theme.screens)
let screenDefinition = screens.find(({ name }) => name === screen)
if (!screenDefinition) {
throw atRule.error(`No \`${screen}\` screen found.`)
}
atRule.name = 'media'
atRule.params = buildMediaQuery(screenDefinition)
})
}
}
+42
View File
@@ -0,0 +1,42 @@
# tailwindcss/nesting
This is a PostCSS plugin that wraps [postcss-nested](https://github.com/postcss/postcss-nested) or [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-nesting) and acts as a compatibility layer to make sure your nesting plugin of choice properly understands Tailwind's custom syntax like `@apply` and `@screen`.
Add it to your PostCSS configuration, somewhere before Tailwind itself:
```js
// postcss.config.js
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss/nesting'),
require('tailwindcss'),
require('autoprefixer'),
]
}
```
By default, it uses the [postcss-nested](https://github.com/postcss/postcss-nested) plugin under the hood, which uses a Sass-like syntax and is the plugin that powers nesting support in the [Tailwind CSS plugin API](https://tailwindcss.com/docs/plugins#css-in-js-syntax).
If you'd rather use [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-nesting) (which is based on the work-in-progress [CSS Nesting](https://drafts.csswg.org/css-nesting-1/) specification), first install the plugin alongside:
```shell
npm install postcss-nesting
```
Then pass the plugin itself as an argument to `tailwindcss/nesting` in your PostCSS configuration:
```js
// postcss.config.js
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss/nesting')(require('postcss-nesting')),
require('tailwindcss'),
require('autoprefixer'),
]
}
```
This can also be helpful if for whatever reason you need to use a very specific version of `postcss-nested` and want to override the version we bundle with `tailwindcss/nesting` itself.
+13
View File
@@ -0,0 +1,13 @@
import { nesting } from './plugin'
export default Object.assign(
function (opts) {
return {
postcssPlugin: 'tailwindcss/nesting',
Once(root, { result }) {
return nesting(opts)(root, result)
},
}
},
{ postcss: true }
)
+80
View File
@@ -0,0 +1,80 @@
import postcss from 'postcss'
import postcssNested from 'postcss-nested'
export function nesting(opts = postcssNested) {
return (root, result) => {
root.walkAtRules('screen', (rule) => {
rule.name = 'media'
rule.params = `screen(${rule.params})`
})
root.walkAtRules('apply', (rule) => {
rule.before(postcss.decl({ prop: '__apply', value: rule.params, source: rule.source }))
rule.remove()
})
let plugin = (() => {
if (
typeof opts === 'function' ||
(typeof opts === 'object' && opts?.hasOwnProperty?.('postcssPlugin'))
) {
return opts
}
if (typeof opts === 'string') {
return require(opts)
}
if (Object.keys(opts).length <= 0) {
return postcssNested
}
throw new Error('tailwindcss/nesting should be loaded with a nesting plugin.')
})()
postcss([plugin]).process(root, result.opts).sync()
root.walkDecls('__apply', (decl) => {
decl.before(postcss.atRule({ name: 'apply', params: decl.value, source: decl.source }))
decl.remove()
})
/**
* Use a private PostCSS API to remove the "clean" flag from the entire AST.
* This is done because running process() on the AST will set the "clean"
* flag on all nodes, which we don't want.
*
* This causes downstream plugins using the visitor API to be skipped.
*
* This is guarded because the PostCSS API is not public
* and may change in future versions of PostCSS.
*
* See https://github.com/postcss/postcss/issues/1712 for more details
*
* @param {import('postcss').Node} node
*/
function markDirty(node) {
if (!('markDirty' in node)) {
return
}
// Traverse the tree down to the leaf nodes
if (node.nodes) {
node.nodes.forEach((n) => markDirty(n))
}
// If it's a leaf node mark it as dirty
// We do this here because marking a node as dirty
// will walk up the tree and mark all parents as dirty
// resulting in a lot of unnecessary work if we did this
// for every single node
if (!node.nodes) {
node.markDirty()
}
}
markDirty(root)
return root
}
}
+58
View File
@@ -0,0 +1,58 @@
import normalizeTailwindDirectives from './lib/normalizeTailwindDirectives'
import expandTailwindAtRules from './lib/expandTailwindAtRules'
import expandApplyAtRules from './lib/expandApplyAtRules'
import evaluateTailwindFunctions from './lib/evaluateTailwindFunctions'
import substituteScreenAtRules from './lib/substituteScreenAtRules'
import resolveDefaultsAtRules from './lib/resolveDefaultsAtRules'
import collapseAdjacentRules from './lib/collapseAdjacentRules'
import collapseDuplicateDeclarations from './lib/collapseDuplicateDeclarations'
import partitionApplyAtRules from './lib/partitionApplyAtRules'
import detectNesting from './lib/detectNesting'
import { createContext } from './lib/setupContextUtils'
import { issueFlagNotices } from './featureFlags'
export default function processTailwindFeatures(setupContext) {
return function (root, result) {
let { tailwindDirectives, applyDirectives } = normalizeTailwindDirectives(root)
detectNesting()(root, result)
// Partition apply rules that are found in the css
// itself.
partitionApplyAtRules()(root, result)
let context = setupContext({
tailwindDirectives,
applyDirectives,
registerDependency(dependency) {
result.messages.push({
plugin: 'tailwindcss',
parent: result.opts.from,
...dependency,
})
},
createContext(tailwindConfig, changedContent) {
return createContext(tailwindConfig, changedContent, root)
},
})(root, result)
if (context.tailwindConfig.separator === '-') {
throw new Error(
"The '-' character cannot be used as a custom separator in JIT mode due to parsing ambiguity. Please use another character like '_' instead."
)
}
issueFlagNotices(context.tailwindConfig)
expandTailwindAtRules(context)(root, result)
// Partition apply rules that are generated by
// addComponents, addUtilities and so on.
partitionApplyAtRules()(root, result)
expandApplyAtRules(context)(root, result)
evaluateTailwindFunctions(context)(root, result)
substituteScreenAtRules(context)(root, result)
resolveDefaultsAtRules(context)(root, result)
collapseAdjacentRules(context)(root, result)
collapseDuplicateDeclarations(context)(root, result)
}
}
+300
View File
@@ -0,0 +1,300 @@
import log from '../util/log'
function warn({ version, from, to }) {
log.warn(`${from}-color-renamed`, [
`As of Tailwind CSS ${version}, \`${from}\` has been renamed to \`${to}\`.`,
'Update your configuration file to silence this warning.',
])
}
export default {
inherit: 'inherit',
current: 'currentColor',
transparent: 'transparent',
black: '#000',
white: '#fff',
slate: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
zinc: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
900: '#18181b',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
},
stone: {
50: '#fafaf9',
100: '#f5f5f4',
200: '#e7e5e4',
300: '#d6d3d1',
400: '#a8a29e',
500: '#78716c',
600: '#57534e',
700: '#44403c',
800: '#292524',
900: '#1c1917',
},
red: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
orange: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
},
amber: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
yellow: {
50: '#fefce8',
100: '#fef9c3',
200: '#fef08a',
300: '#fde047',
400: '#facc15',
500: '#eab308',
600: '#ca8a04',
700: '#a16207',
800: '#854d0e',
900: '#713f12',
},
lime: {
50: '#f7fee7',
100: '#ecfccb',
200: '#d9f99d',
300: '#bef264',
400: '#a3e635',
500: '#84cc16',
600: '#65a30d',
700: '#4d7c0f',
800: '#3f6212',
900: '#365314',
},
green: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
emerald: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
},
teal: {
50: '#f0fdfa',
100: '#ccfbf1',
200: '#99f6e4',
300: '#5eead4',
400: '#2dd4bf',
500: '#14b8a6',
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
},
cyan: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#06b6d4',
600: '#0891b2',
700: '#0e7490',
800: '#155e75',
900: '#164e63',
},
sky: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
blue: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
indigo: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
},
violet: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
purple: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
},
fuchsia: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
},
pink: {
50: '#fdf2f8',
100: '#fce7f3',
200: '#fbcfe8',
300: '#f9a8d4',
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
700: '#be185d',
800: '#9d174d',
900: '#831843',
},
rose: {
50: '#fff1f2',
100: '#ffe4e6',
200: '#fecdd3',
300: '#fda4af',
400: '#fb7185',
500: '#f43f5e',
600: '#e11d48',
700: '#be123c',
800: '#9f1239',
900: '#881337',
},
get lightBlue() {
warn({ version: 'v2.2', from: 'lightBlue', to: 'sky' })
return this.sky
},
get warmGray() {
warn({ version: 'v3.0', from: 'warmGray', to: 'stone' })
return this.stone
},
get trueGray() {
warn({ version: 'v3.0', from: 'trueGray', to: 'neutral' })
return this.neutral
},
get coolGray() {
warn({ version: 'v3.0', from: 'coolGray', to: 'gray' })
return this.gray
},
get blueGray() {
warn({ version: 'v3.0', from: 'blueGray', to: 'slate' })
return this.slate
},
}
+2
View File
@@ -0,0 +1,2 @@
import createPlugin from '../util/createPlugin'
export default createPlugin
+4
View File
@@ -0,0 +1,4 @@
import { cloneDeep } from '../util/cloneDeep'
import defaultConfig from '../../stubs/defaultConfig.stub'
export default cloneDeep(defaultConfig)
+4
View File
@@ -0,0 +1,4 @@
import { cloneDeep } from '../util/cloneDeep'
import defaultConfig from '../../stubs/defaultConfig.stub'
export default cloneDeep(defaultConfig.theme)
+7
View File
@@ -0,0 +1,7 @@
import resolveConfigObjects from '../util/resolveConfig'
import getAllConfigs from '../util/getAllConfigs'
export default function resolveConfig(...configs) {
let [, ...defaultConfigs] = getAllConfigs(configs[0])
return resolveConfigObjects([...configs, ...defaultConfigs])
}
+3
View File
@@ -0,0 +1,3 @@
export default function bigSign(bigIntValue) {
return (bigIntValue > 0n) - (bigIntValue < 0n)
}
+20
View File
@@ -0,0 +1,20 @@
export default function buildMediaQuery(screens) {
screens = Array.isArray(screens) ? screens : [screens]
return screens
.map((screen) =>
screen.values.map((screen) => {
if (screen.raw !== undefined) {
return screen.raw
}
return [
screen.min && `(min-width: ${screen.min})`,
screen.max && `(max-width: ${screen.max})`,
]
.filter(Boolean)
.join(' and ')
})
)
.join(', ')
}
+11
View File
@@ -0,0 +1,11 @@
export function cloneDeep(value) {
if (Array.isArray(value)) {
return value.map((child) => cloneDeep(child))
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, cloneDeep(v)]))
}
return value
}
+28
View File
@@ -0,0 +1,28 @@
export default function cloneNodes(nodes, source = undefined, raws = undefined) {
return nodes.map((node) => {
let cloned = node.clone()
// We always want override the source map
// except when explicitly told not to
let shouldOverwriteSource = node.raws.tailwind?.preserveSource !== true || !cloned.source
if (source !== undefined && shouldOverwriteSource) {
cloned.source = source
if ('walk' in cloned) {
cloned.walk((child) => {
child.source = source
})
}
}
if (raws !== undefined) {
cloned.raws.tailwind = {
...cloned.raws.tailwind,
...raws,
}
}
return cloned
})
}
+73
View File
@@ -0,0 +1,73 @@
import namedColors from 'color-name'
let HEX = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i
let SHORT_HEX = /^#([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i
let VALUE = /(?:\d+|\d*\.\d+)%?/
let SEP = /(?:\s*,\s*|\s+)/
let ALPHA_SEP = /\s*[,/]\s*/
let CUSTOM_PROPERTY = /var\(--(?:[^ )]*?)\)/
let RGB = new RegExp(
`^(rgb)a?\\(\\s*(${VALUE.source}|${CUSTOM_PROPERTY.source})(?:${SEP.source}(${VALUE.source}|${CUSTOM_PROPERTY.source}))?(?:${SEP.source}(${VALUE.source}|${CUSTOM_PROPERTY.source}))?(?:${ALPHA_SEP.source}(${VALUE.source}|${CUSTOM_PROPERTY.source}))?\\s*\\)$`
)
let HSL = new RegExp(
`^(hsl)a?\\(\\s*((?:${VALUE.source})(?:deg|rad|grad|turn)?|${CUSTOM_PROPERTY.source})(?:${SEP.source}(${VALUE.source}|${CUSTOM_PROPERTY.source}))?(?:${SEP.source}(${VALUE.source}|${CUSTOM_PROPERTY.source}))?(?:${ALPHA_SEP.source}(${VALUE.source}|${CUSTOM_PROPERTY.source}))?\\s*\\)$`
)
// In "loose" mode the color may contain fewer than 3 parts, as long as at least
// one of the parts is variable.
export function parseColor(value, { loose = false } = {}) {
if (typeof value !== 'string') {
return null
}
value = value.trim()
if (value === 'transparent') {
return { mode: 'rgb', color: ['0', '0', '0'], alpha: '0' }
}
if (value in namedColors) {
return { mode: 'rgb', color: namedColors[value].map((v) => v.toString()) }
}
let hex = value
.replace(SHORT_HEX, (_, r, g, b, a) => ['#', r, r, g, g, b, b, a ? a + a : ''].join(''))
.match(HEX)
if (hex !== null) {
return {
mode: 'rgb',
color: [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)].map((v) =>
v.toString()
),
alpha: hex[4] ? (parseInt(hex[4], 16) / 255).toString() : undefined,
}
}
let match = value.match(RGB) ?? value.match(HSL)
if (match === null) {
return null
}
let color = [match[2], match[3], match[4]].filter(Boolean).map((v) => v.toString())
if (!loose && color.length !== 3) {
return null
}
if (color.length < 3 && !color.some((part) => /^var\(.*?\)$/.test(part))) {
return null
}
return {
mode: match[1],
color,
alpha: match[5]?.toString?.(),
}
}
export function formatColor({ mode, color, alpha }) {
let hasAlpha = alpha !== undefined
return `${mode}(${color.join(' ')}${hasAlpha ? ` / ${alpha}` : ''})`
}
+23
View File
@@ -0,0 +1,23 @@
export default function (pluginConfig, plugins) {
if (pluginConfig === undefined) {
return plugins
}
const pluginNames = Array.isArray(pluginConfig)
? pluginConfig
: [
...new Set(
plugins
.filter((pluginName) => {
return pluginConfig !== false && pluginConfig[pluginName] !== false
})
.concat(
Object.keys(pluginConfig).filter((pluginName) => {
return pluginConfig[pluginName] !== false
})
)
),
]
return pluginNames
}
+27
View File
@@ -0,0 +1,27 @@
function createPlugin(plugin, config) {
return {
handler: plugin,
config,
}
}
createPlugin.withOptions = function (pluginFunction, configFunction = () => ({})) {
const optionsFunction = function (options) {
return {
__options: options,
handler: pluginFunction(options),
config: configFunction(options),
}
}
optionsFunction.__isOptionsFunction = true
// Expose plugin dependencies so that `object-hash` returns a different
// value if anything here changes, to ensure a rebuild is triggered.
optionsFunction.__pluginFunction = pluginFunction
optionsFunction.__configFunction = configFunction
return optionsFunction
}
export default createPlugin
+37
View File
@@ -0,0 +1,37 @@
import transformThemeValue from './transformThemeValue'
export default function createUtilityPlugin(
themeKey,
utilityVariations = [[themeKey, [themeKey]]],
{ filterDefault = false, ...options } = {}
) {
let transformValue = transformThemeValue(themeKey)
return function ({ matchUtilities, theme }) {
for (let utilityVariation of utilityVariations) {
let group = Array.isArray(utilityVariation[0]) ? utilityVariation : [utilityVariation]
matchUtilities(
group.reduce((obj, [classPrefix, properties]) => {
return Object.assign(obj, {
[classPrefix]: (value) => {
return properties.reduce((obj, name) => {
if (Array.isArray(name)) {
return Object.assign(obj, { [name[0]]: name[1] })
}
return Object.assign(obj, { [name]: transformValue(value) })
}, {})
},
})
}, {}),
{
...options,
values: filterDefault
? Object.fromEntries(
Object.entries(theme(themeKey) ?? {}).filter(([modifier]) => modifier !== 'DEFAULT')
)
: theme(themeKey),
}
)
}
}
}
+254
View File
@@ -0,0 +1,254 @@
import { parseColor } from './color'
import { parseBoxShadowValue } from './parseBoxShadowValue'
let cssFunctions = ['min', 'max', 'clamp', 'calc']
// Ref: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Types
let COMMA = /,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count.
let UNDERSCORE = /_(?![^(]*\))/g // Underscore separator that is not located between brackets. E.g.: `rgba(255,_255,_255)_black` these don't count.
// This is not a data type, but rather a function that can normalize the
// correct values.
export function normalize(value, isRoot = true) {
// Keep raw strings if it starts with `url(`
if (value.includes('url(')) {
return value
.split(/(url\(.*?\))/g)
.filter(Boolean)
.map((part) => {
if (/^url\(.*?\)$/.test(part)) {
return part
}
return normalize(part, false)
})
.join('')
}
// Convert `_` to ` `, except for escaped underscores `\_`
value = value
.replace(
/([^\\])_+/g,
(fullMatch, characterBefore) => characterBefore + ' '.repeat(fullMatch.length - 1)
)
.replace(/^_/g, ' ')
.replace(/\\_/g, '_')
// Remove leftover whitespace
if (isRoot) {
value = value.trim()
}
// Add spaces around operators inside math functions like calc() that do not follow an operator
// or '('.
value = value.replace(/(calc|min|max|clamp)\(.+\)/g, (match) => {
return match.replace(
/(-?\d*\.?\d(?!\b-.+[,)](?![^+\-/*])\D)(?:%|[a-z]+)?|\))([+\-/*])/g,
'$1 $2 '
)
})
return value
}
export function url(value) {
return value.startsWith('url(')
}
export function number(value) {
return !isNaN(Number(value)) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?`).test(value))
}
export function percentage(value) {
return value.split(UNDERSCORE).every((part) => {
return /%$/g.test(part) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?%`).test(part))
})
}
let lengthUnits = [
'cm',
'mm',
'Q',
'in',
'pc',
'pt',
'px',
'em',
'ex',
'ch',
'rem',
'lh',
'vw',
'vh',
'vmin',
'vmax',
]
let lengthUnitsPattern = `(?:${lengthUnits.join('|')})`
export function length(value) {
return value.split(UNDERSCORE).every((part) => {
return (
part === '0' ||
new RegExp(`${lengthUnitsPattern}$`).test(part) ||
cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?${lengthUnitsPattern}`).test(part))
)
})
}
let lineWidths = new Set(['thin', 'medium', 'thick'])
export function lineWidth(value) {
return lineWidths.has(value)
}
export function shadow(value) {
let parsedShadows = parseBoxShadowValue(normalize(value))
for (let parsedShadow of parsedShadows) {
if (!parsedShadow.valid) {
return false
}
}
return true
}
export function color(value) {
let colors = 0
let result = value.split(UNDERSCORE).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
if (parseColor(part, { loose: true }) !== null) return colors++, true
return false
})
if (!result) return false
return colors > 0
}
export function image(value) {
let images = 0
let result = value.split(COMMA).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
if (
url(part) ||
gradient(part) ||
['element(', 'image(', 'cross-fade(', 'image-set('].some((fn) => part.startsWith(fn))
) {
images++
return true
}
return false
})
if (!result) return false
return images > 0
}
let gradientTypes = new Set([
'linear-gradient',
'radial-gradient',
'repeating-linear-gradient',
'repeating-radial-gradient',
'conic-gradient',
])
export function gradient(value) {
value = normalize(value)
for (let type of gradientTypes) {
if (value.startsWith(`${type}(`)) {
return true
}
}
return false
}
let validPositions = new Set(['center', 'top', 'right', 'bottom', 'left'])
export function position(value) {
let positions = 0
let result = value.split(UNDERSCORE).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
if (validPositions.has(part) || length(part) || percentage(part)) {
positions++
return true
}
return false
})
if (!result) return false
return positions > 0
}
export function familyName(value) {
let fonts = 0
let result = value.split(COMMA).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
// If it contains spaces, then it should be quoted
if (part.includes(' ')) {
if (!/(['"])([^"']+)\1/g.test(part)) {
return false
}
}
// If it starts with a number, it's invalid
if (/^\d/g.test(part)) {
return false
}
fonts++
return true
})
if (!result) return false
return fonts > 0
}
let genericNames = new Set([
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'ui-rounded',
'math',
'emoji',
'fangsong',
])
export function genericName(value) {
return genericNames.has(value)
}
let absoluteSizes = new Set([
'xx-small',
'x-small',
'small',
'medium',
'large',
'x-large',
'x-large',
'xxx-large',
])
export function absoluteSize(value) {
return absoluteSizes.has(value)
}
let relativeSizes = new Set(['larger', 'smaller'])
export function relativeSize(value) {
return relativeSizes.has(value)
}
+17
View File
@@ -0,0 +1,17 @@
export function defaults(target, ...sources) {
for (let source of sources) {
for (let k in source) {
if (!target?.hasOwnProperty?.(k)) {
target[k] = source[k]
}
}
for (let k of Object.getOwnPropertySymbols(source)) {
if (!target?.hasOwnProperty?.(k)) {
target[k] = source[k]
}
}
}
return target
}
+8
View File
@@ -0,0 +1,8 @@
import parser from 'postcss-selector-parser'
import escapeCommas from './escapeCommas'
export default function escapeClassName(className) {
let node = parser.className()
node.value = className
return escapeCommas(node?.raws?.value ?? node.value)
}
+3
View File
@@ -0,0 +1,3 @@
export default function escapeCommas(className) {
return className.replace(/\\,/g, '\\2c ')
}
+13
View File
@@ -0,0 +1,13 @@
const flattenColorPalette = (colors) =>
Object.assign(
{},
...Object.entries(colors ?? {}).flatMap(([color, values]) =>
typeof values == 'object'
? Object.entries(flattenColorPalette(values)).map(([number, hex]) => ({
[color + (number === 'DEFAULT' ? '' : `-${number}`)]: hex,
}))
: [{ [`${color}`]: values }]
)
)
export default flattenColorPalette
+215
View File
@@ -0,0 +1,215 @@
import selectorParser from 'postcss-selector-parser'
import unescape from 'postcss-selector-parser/dist/util/unesc'
import escapeClassName from '../util/escapeClassName'
import prefixSelector from '../util/prefixSelector'
let MERGE = ':merge'
let PARENT = '&'
export let selectorFunctions = new Set([MERGE])
export function formatVariantSelector(current, ...others) {
for (let other of others) {
let incomingValue = resolveFunctionArgument(other, MERGE)
if (incomingValue !== null) {
let existingValue = resolveFunctionArgument(current, MERGE, incomingValue)
if (existingValue !== null) {
let existingTarget = `${MERGE}(${incomingValue})`
let splitIdx = other.indexOf(existingTarget)
let addition = other.slice(splitIdx + existingTarget.length).split(' ')[0]
current = current.replace(existingTarget, existingTarget + addition)
continue
}
}
current = other.replace(PARENT, current)
}
return current
}
export function finalizeSelector(
format,
{
selector,
candidate,
context,
isArbitraryVariant,
// Split by the separator, but ignore the separator inside square brackets:
//
// E.g.: dark:lg:hover:[paint-order:markers]
// ┬ ┬ ┬ ┬
// │ │ │ ╰── We will not split here
// ╰──┴─────┴─────────────── We will split here
//
base = candidate
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
.pop(),
}
) {
let ast = selectorParser().astSync(selector)
// We explicitly DO NOT prefix classes in arbitrary variants
if (context?.tailwindConfig?.prefix && !isArbitraryVariant) {
format = prefixSelector(context.tailwindConfig.prefix, format)
}
format = format.replace(PARENT, `.${escapeClassName(candidate)}`)
let formatAst = selectorParser().astSync(format)
// Remove extraneous selectors that do not include the base class/candidate being matched against
// For example if we have a utility defined `.a, .b { color: red}`
// And the formatted variant is sm:b then we want the final selector to be `.sm\:b` and not `.a, .sm\:b`
ast.each((node) => {
let hasClassesMatchingCandidate = node.some((n) => n.type === 'class' && n.value === base)
if (!hasClassesMatchingCandidate) {
node.remove()
}
})
// Normalize escaped classes, e.g.:
//
// The idea would be to replace the escaped `base` in the selector with the
// `format`. However, in css you can escape the same selector in a few
// different ways. This would result in different strings and therefore we
// can't replace it properly.
//
// base: bg-[rgb(255,0,0)]
// base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\]
// escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\]
//
ast.walkClasses((node) => {
if (node.raws && node.value.includes(base)) {
node.raws.value = escapeClassName(unescape(node.raws.value))
}
})
// We can safely replace the escaped base now, since the `base` section is
// now in a normalized escaped value.
ast.walkClasses((node) => {
if (node.value === base) {
node.replaceWith(...formatAst.nodes)
}
})
// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
function collectPseudoElements(selector) {
let nodes = []
for (let node of selector.nodes) {
if (isPseudoElement(node)) {
nodes.push(node)
selector.removeChild(node)
}
if (node?.nodes) {
nodes.push(...collectPseudoElements(node))
}
}
return nodes
}
// Remove unnecessary pseudo selectors that we used as placeholders
ast.each((selector) => {
selector.walkPseudos((p) => {
if (selectorFunctions.has(p.value)) {
p.replaceWith(p.nodes)
}
})
let pseudoElements = collectPseudoElements(selector)
if (pseudoElements.length > 0) {
selector.nodes.push(pseudoElements.sort(sortSelector))
}
})
return ast.toString()
}
// Note: As a rule, double colons (::) should be used instead of a single colon
// (:). This distinguishes pseudo-classes from pseudo-elements. However, since
// this distinction was not present in older versions of the W3C spec, most
// browsers support both syntaxes for the original pseudo-elements.
let pseudoElementsBC = [':before', ':after', ':first-line', ':first-letter']
// These pseudo-elements _can_ be combined with other pseudo selectors AND the order does matter.
let pseudoElementExceptions = ['::file-selector-button']
// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work
// for you by flipping the order.
function sortSelector(a, z) {
// Both nodes are non-pseudo's so we can safely ignore them and keep
// them in the same order.
if (a.type !== 'pseudo' && z.type !== 'pseudo') {
return 0
}
// If one of them is a combinator, we need to keep it in the same order
// because that means it will start a new "section" in the selector.
if ((a.type === 'combinator') ^ (z.type === 'combinator')) {
return 0
}
// One of the items is a pseudo and the other one isn't. Let's move
// the pseudo to the right.
if ((a.type === 'pseudo') ^ (z.type === 'pseudo')) {
return (a.type === 'pseudo') - (z.type === 'pseudo')
}
// Both are pseudo's, move the pseudo elements (except for
// ::file-selector-button) to the right.
return isPseudoElement(a) - isPseudoElement(z)
}
function isPseudoElement(node) {
if (node.type !== 'pseudo') return false
if (pseudoElementExceptions.includes(node.value)) return false
return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
}
function resolveFunctionArgument(haystack, needle, arg) {
let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle)
if (startIdx === -1) return null
// Start inside the `(`
startIdx += needle.length + 1
let target = ''
let count = 0
for (let char of haystack.slice(startIdx)) {
if (char !== '(' && char !== ')') {
target += char
} else if (char === '(') {
target += char
count++
} else if (char === ')') {
if (--count < 0) break // unbalanced
target += char
}
}
return target
}
+26
View File
@@ -0,0 +1,26 @@
import defaultConfig from '../../stubs/defaultConfig.stub.js'
import { flagEnabled } from '../featureFlags'
export default function getAllConfigs(config) {
const configs = (config?.presets ?? [defaultConfig])
.slice()
.reverse()
.flatMap((preset) => getAllConfigs(preset instanceof Function ? preset() : preset))
const features = {
// Add experimental configs here...
respectDefaultRingColorOpacity: {
theme: {
ringColor: {
DEFAULT: '#3b82f67f',
},
},
},
}
const experimentals = Object.keys(features)
.filter((feature) => flagEnabled(config, feature))
.map((feature) => features[feature])
return [config, ...experimentals, ...configs]
}
+5
View File
@@ -0,0 +1,5 @@
import hash from 'object-hash'
export default function hashConfig(config) {
return hash(config, { ignoreUnknown: true })
}
+3
View File
@@ -0,0 +1,3 @@
export default function isKeyframeRule(rule) {
return rule.parent && rule.parent.type === 'atrule' && /keyframes$/.test(rule.parent.name)
}
+8
View File
@@ -0,0 +1,8 @@
export default function isPlainObject(value) {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return false
}
const prototype = Object.getPrototypeOf(value)
return prototype === null || prototype === Object.prototype
}
+61
View File
@@ -0,0 +1,61 @@
let matchingBrackets = new Map([
['{', '}'],
['[', ']'],
['(', ')'],
])
let inverseMatchingBrackets = new Map(
Array.from(matchingBrackets.entries()).map(([k, v]) => [v, k])
)
let quotes = new Set(['"', "'", '`'])
// Arbitrary values must contain balanced brackets (), [] and {}. Escaped
// values don't count, and brackets inside quotes also don't count.
//
// E.g.: w-[this-is]w-[weird-and-invalid]
// E.g.: w-[this-is\\]w-\\[weird-but-valid]
// E.g.: content-['this-is-also-valid]-weirdly-enough']
export default function isValidArbitraryValue(value) {
let stack = []
let inQuotes = false
for (let i = 0; i < value.length; i++) {
let char = value[i]
if (char === ':' && !inQuotes && stack.length === 0) {
return false
}
// Non-escaped quotes allow us to "allow" anything in between
if (quotes.has(char) && value[i - 1] !== '\\') {
inQuotes = !inQuotes
}
if (inQuotes) continue
if (value[i - 1] === '\\') continue // Escaped
if (matchingBrackets.has(char)) {
stack.push(char)
} else if (inverseMatchingBrackets.has(char)) {
let inverse = inverseMatchingBrackets.get(char)
// Nothing to pop from, therefore it is unbalanced
if (stack.length <= 0) {
return false
}
// Popped value must match the inverse value, otherwise it is unbalanced
if (stack.pop() !== inverse) {
return false
}
}
}
// If there is still something on the stack, it is also unbalanced
if (stack.length > 0) {
return false
}
// All good, totally balanced!
return true
}
+29
View File
@@ -0,0 +1,29 @@
import colors from 'picocolors'
let alreadyShown = new Set()
function log(type, messages, key) {
if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID) return
if (key && alreadyShown.has(key)) return
if (key) alreadyShown.add(key)
console.warn('')
messages.forEach((message) => console.warn(type, '-', message))
}
export function dim(input) {
return colors.dim(input)
}
export default {
info(key, messages) {
log(colors.bold(colors.cyan('info')), ...(Array.isArray(key) ? [key] : [messages, key]))
},
warn(key, messages) {
log(colors.bold(colors.yellow('warn')), ...(Array.isArray(key) ? [key] : [messages, key]))
},
risk(key, messages) {
log(colors.bold(colors.magenta('risk')), ...(Array.isArray(key) ? [key] : [messages, key]))
},
}
+26
View File
@@ -0,0 +1,26 @@
import escapeClassName from './escapeClassName'
import escapeCommas from './escapeCommas'
export function asClass(name) {
return escapeCommas(`.${escapeClassName(name)}`)
}
export default function nameClass(classPrefix, key) {
return asClass(formatClass(classPrefix, key))
}
export function formatClass(classPrefix, key) {
if (key === 'DEFAULT') {
return classPrefix
}
if (key === '-' || key === '-DEFAULT') {
return `-${classPrefix}`
}
if (key.startsWith('-')) {
return `-${classPrefix}${key}`
}
return `${classPrefix}-${key}`
}
+16
View File
@@ -0,0 +1,16 @@
export default function (value) {
value = `${value}`
if (value === '0') {
return '0'
}
// Flip sign of numbers
if (/^[+-]?(\d+|\d*\.\d+)(e[+-]?\d+)?(%|\w+)?$/.test(value)) {
return value.replace(/^[+-]?/, (sign) => (sign === '-' ? '' : '-'))
}
if (value.includes('var(') || value.includes('calc(')) {
return `calc(${value} * -1)`
}
}
+262
View File
@@ -0,0 +1,262 @@
import log, { dim } from './log'
export function normalizeConfig(config) {
// Quick structure validation
/**
* type FilePath = string
* type RawFile = { raw: string, extension?: string }
* type ExtractorFn = (content: string) => Array<string>
* type TransformerFn = (content: string) => string
*
* type Content =
* | Array<FilePath | RawFile>
* | {
* files: Array<FilePath | RawFile>,
* extract?: ExtractorFn | { [extension: string]: ExtractorFn }
* transform?: TransformerFn | { [extension: string]: TransformerFn }
* }
*/
let valid = (() => {
// `config.purge` should not exist anymore
if (config.purge) {
return false
}
// `config.content` should exist
if (!config.content) {
return false
}
// `config.content` should be an object or an array
if (
!Array.isArray(config.content) &&
!(typeof config.content === 'object' && config.content !== null)
) {
return false
}
// When `config.content` is an array, it should consist of FilePaths or RawFiles
if (Array.isArray(config.content)) {
return config.content.every((path) => {
// `path` can be a string
if (typeof path === 'string') return true
// `path` can be an object { raw: string, extension?: string }
// `raw` must be a string
if (typeof path?.raw !== 'string') return false
// `extension` (if provided) should also be a string
if (path?.extension && typeof path?.extension !== 'string') {
return false
}
return true
})
}
// When `config.content` is an object
if (typeof config.content === 'object' && config.content !== null) {
// Only `files`, `extract` and `transform` can exist in `config.content`
if (
Object.keys(config.content).some((key) => !['files', 'extract', 'transform'].includes(key))
) {
return false
}
// `config.content.files` should exist of FilePaths or RawFiles
if (Array.isArray(config.content.files)) {
if (
!config.content.files.every((path) => {
// `path` can be a string
if (typeof path === 'string') return true
// `path` can be an object { raw: string, extension?: string }
// `raw` must be a string
if (typeof path?.raw !== 'string') return false
// `extension` (if provided) should also be a string
if (path?.extension && typeof path?.extension !== 'string') {
return false
}
return true
})
) {
return false
}
// `config.content.extract` is optional, and can be a Function or a Record<String, Function>
if (typeof config.content.extract === 'object') {
for (let value of Object.values(config.content.extract)) {
if (typeof value !== 'function') {
return false
}
}
} else if (
!(config.content.extract === undefined || typeof config.content.extract === 'function')
) {
return false
}
// `config.content.transform` is optional, and can be a Function or a Record<String, Function>
if (typeof config.content.transform === 'object') {
for (let value of Object.values(config.content.transform)) {
if (typeof value !== 'function') {
return false
}
}
} else if (
!(
config.content.transform === undefined || typeof config.content.transform === 'function'
)
) {
return false
}
}
return true
}
return false
})()
if (!valid) {
log.warn('purge-deprecation', [
'The `purge`/`content` options have changed in Tailwind CSS v3.0.',
'Update your configuration file to eliminate this warning.',
'https://tailwindcss.com/docs/upgrade-guide#configure-content-sources',
])
}
// Normalize the `safelist`
config.safelist = (() => {
let { content, purge, safelist } = config
if (Array.isArray(safelist)) return safelist
if (Array.isArray(content?.safelist)) return content.safelist
if (Array.isArray(purge?.safelist)) return purge.safelist
if (Array.isArray(purge?.options?.safelist)) return purge.options.safelist
return []
})()
// Normalize prefix option
if (typeof config.prefix === 'function') {
log.warn('prefix-function', [
'As of Tailwind CSS v3.0, `prefix` cannot be a function.',
'Update `prefix` in your configuration to be a string to eliminate this warning.',
'https://tailwindcss.com/docs/upgrade-guide#prefix-cannot-be-a-function',
])
config.prefix = ''
} else {
config.prefix = config.prefix ?? ''
}
// Normalize the `content`
config.content = {
files: (() => {
let { content, purge } = config
if (Array.isArray(purge)) return purge
if (Array.isArray(purge?.content)) return purge.content
if (Array.isArray(content)) return content
if (Array.isArray(content?.content)) return content.content
if (Array.isArray(content?.files)) return content.files
return []
})(),
extract: (() => {
let extract = (() => {
if (config.purge?.extract) return config.purge.extract
if (config.content?.extract) return config.content.extract
if (config.purge?.extract?.DEFAULT) return config.purge.extract.DEFAULT
if (config.content?.extract?.DEFAULT) return config.content.extract.DEFAULT
if (config.purge?.options?.extractors) return config.purge.options.extractors
if (config.content?.options?.extractors) return config.content.options.extractors
return {}
})()
let extractors = {}
let defaultExtractor = (() => {
if (config.purge?.options?.defaultExtractor) {
return config.purge.options.defaultExtractor
}
if (config.content?.options?.defaultExtractor) {
return config.content.options.defaultExtractor
}
return undefined
})()
if (defaultExtractor !== undefined) {
extractors.DEFAULT = defaultExtractor
}
// Functions
if (typeof extract === 'function') {
extractors.DEFAULT = extract
}
// Arrays
else if (Array.isArray(extract)) {
for (let { extensions, extractor } of extract ?? []) {
for (let extension of extensions) {
extractors[extension] = extractor
}
}
}
// Objects
else if (typeof extract === 'object' && extract !== null) {
Object.assign(extractors, extract)
}
return extractors
})(),
transform: (() => {
let transform = (() => {
if (config.purge?.transform) return config.purge.transform
if (config.content?.transform) return config.content.transform
if (config.purge?.transform?.DEFAULT) return config.purge.transform.DEFAULT
if (config.content?.transform?.DEFAULT) return config.content.transform.DEFAULT
return {}
})()
let transformers = {}
if (typeof transform === 'function') {
transformers.DEFAULT = transform
}
if (typeof transform === 'object' && transform !== null) {
Object.assign(transformers, transform)
}
return transformers
})(),
}
// Validate globs to prevent bogus globs.
// E.g.: `./src/*.{html}` is invalid, the `{html}` should just be `html`
for (let file of config.content.files) {
if (typeof file === 'string' && /{([^,]*?)}/g.test(file)) {
log.warn('invalid-glob-braces', [
`The glob pattern ${dim(file)} in your Tailwind CSS configuration is invalid.`,
`Update it to ${dim(file.replace(/{([^,]*?)}/g, '$1'))} to silence this warning.`,
// TODO: Add https://tw.wtf/invalid-glob-braces
])
break
}
}
return config
}
+45
View File
@@ -0,0 +1,45 @@
/**
* A function that normalizes the various forms that the screens object can be
* provided in.
*
* Input(s):
* - ['100px', '200px'] // Raw strings
* - { sm: '100px', md: '200px' } // Object with string values
* - { sm: { min: '100px' }, md: { max: '100px' } } // Object with object values
* - { sm: [{ min: '100px' }, { max: '200px' }] } // Object with object array (multiple values)
*
* Output(s):
* - [{ name: 'sm', values: [{ min: '100px', max: '200px' }] }] // List of objects, that contains multiple values
*/
export function normalizeScreens(screens, root = true) {
if (Array.isArray(screens)) {
return screens.map((screen) => {
if (root && Array.isArray(screen)) {
throw new Error('The tuple syntax is not supported for `screens`.')
}
if (typeof screen === 'string') {
return { name: screen.toString(), values: [{ min: screen, max: undefined }] }
}
let [name, options] = screen
name = name.toString()
if (typeof options === 'string') {
return { name, values: [{ min: options, max: undefined }] }
}
if (Array.isArray(options)) {
return { name, values: options.map((option) => resolveValue(option)) }
}
return { name, values: [resolveValue(options)] }
})
}
return normalizeScreens(Object.entries(screens ?? {}), false)
}
function resolveValue({ 'min-width': _minWidth, min = _minWidth, max, raw } = {}) {
return { min, max, raw }
}
+68
View File
@@ -0,0 +1,68 @@
const DIRECTIONS = new Set(['normal', 'reverse', 'alternate', 'alternate-reverse'])
const PLAY_STATES = new Set(['running', 'paused'])
const FILL_MODES = new Set(['none', 'forwards', 'backwards', 'both'])
const ITERATION_COUNTS = new Set(['infinite'])
const TIMINGS = new Set([
'linear',
'ease',
'ease-in',
'ease-out',
'ease-in-out',
'step-start',
'step-end',
])
const TIMING_FNS = ['cubic-bezier', 'steps']
const COMMA = /\,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count.
const SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces instead.
const TIME = /^(-?[\d.]+m?s)$/
const DIGIT = /^(\d+)$/
export default function parseAnimationValue(input) {
let animations = input.split(COMMA)
return animations.map((animation) => {
let value = animation.trim()
let result = { value }
let parts = value.split(SPACE)
let seen = new Set()
for (let part of parts) {
if (!seen.has('DIRECTIONS') && DIRECTIONS.has(part)) {
result.direction = part
seen.add('DIRECTIONS')
} else if (!seen.has('PLAY_STATES') && PLAY_STATES.has(part)) {
result.playState = part
seen.add('PLAY_STATES')
} else if (!seen.has('FILL_MODES') && FILL_MODES.has(part)) {
result.fillMode = part
seen.add('FILL_MODES')
} else if (
!seen.has('ITERATION_COUNTS') &&
(ITERATION_COUNTS.has(part) || DIGIT.test(part))
) {
result.iterationCount = part
seen.add('ITERATION_COUNTS')
} else if (!seen.has('TIMING_FUNCTION') && TIMINGS.has(part)) {
result.timingFunction = part
seen.add('TIMING_FUNCTION')
} else if (!seen.has('TIMING_FUNCTION') && TIMING_FNS.some((f) => part.startsWith(`${f}(`))) {
result.timingFunction = part
seen.add('TIMING_FUNCTION')
} else if (!seen.has('DURATION') && TIME.test(part)) {
result.duration = part
seen.add('DURATION')
} else if (!seen.has('DELAY') && TIME.test(part)) {
result.delay = part
seen.add('DELAY')
} else if (!seen.has('NAME')) {
result.name = part
seen.add('NAME')
} else {
if (!result.unknown) result.unknown = []
result.unknown.push(part)
}
}
return result
})
}
+72
View File
@@ -0,0 +1,72 @@
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'
let KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces instead.
let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g
export function parseBoxShadowValue(input) {
let shadows = Array.from(splitAtTopLevelOnly(input, ','))
return shadows.map((shadow) => {
let value = shadow.trim()
let result = { raw: value }
let parts = value.split(SPACE)
let seen = new Set()
for (let part of parts) {
// Reset index, since the regex is stateful.
LENGTH.lastIndex = 0
// Keyword
if (!seen.has('KEYWORD') && KEYWORDS.has(part)) {
result.keyword = part
seen.add('KEYWORD')
}
// Length value
else if (LENGTH.test(part)) {
if (!seen.has('X')) {
result.x = part
seen.add('X')
} else if (!seen.has('Y')) {
result.y = part
seen.add('Y')
} else if (!seen.has('BLUR')) {
result.blur = part
seen.add('BLUR')
} else if (!seen.has('SPREAD')) {
result.spread = part
seen.add('SPREAD')
}
}
// Color or unknown
else {
if (!result.color) {
result.color = part
} else {
if (!result.unknown) result.unknown = []
result.unknown.push(part)
}
}
}
// Check if valid
result.valid = result.x !== undefined && result.y !== undefined
return result
})
}
export function formatBoxShadowValue(shadows) {
return shadows
.map((shadow) => {
if (!shadow.valid) {
return shadow.raw
}
return [shadow.keyword, shadow.x, shadow.y, shadow.blur, shadow.spread, shadow.color]
.filter(Boolean)
.join(' ')
})
.join(', ')
}
+49
View File
@@ -0,0 +1,49 @@
import isGlob from 'is-glob'
import globParent from 'glob-parent'
import path from 'path'
// Based on `glob-base`
// https://github.com/micromatch/glob-base/blob/master/index.js
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 }
}
export default function parseDependency(normalizedFileOrGlob) {
if (normalizedFileOrGlob.startsWith('!')) {
return null
}
let message
if (isGlob(normalizedFileOrGlob)) {
let { base, glob } = parseGlob(normalizedFileOrGlob)
message = { type: 'dir-dependency', dir: path.resolve(base), glob }
} else {
message = { type: 'dependency', file: path.resolve(normalizedFileOrGlob) }
}
// 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 }
}
return message
}
+19
View File
@@ -0,0 +1,19 @@
import postcss from 'postcss'
import postcssNested from 'postcss-nested'
import postcssJs from 'postcss-js'
export default function parseObjectStyles(styles) {
if (!Array.isArray(styles)) {
return parseObjectStyles([styles])
}
return styles.flatMap((style) => {
return postcss([
postcssNested({
bubble: ['screen'],
}),
]).process(style, {
parser: postcssJs,
}).root.nodes
})
}
+204
View File
@@ -0,0 +1,204 @@
import selectorParser from 'postcss-selector-parser'
import escapeCommas from './escapeCommas'
import { withAlphaValue } from './withAlphaVariable'
import {
normalize,
length,
number,
percentage,
url,
color as validateColor,
genericName,
familyName,
image,
absoluteSize,
relativeSize,
position,
lineWidth,
shadow,
} from './dataTypes'
import negateValue from './negateValue'
export function updateAllClasses(selectors, updateClass) {
let parser = selectorParser((selectors) => {
selectors.walkClasses((sel) => {
let updatedClass = updateClass(sel.value)
sel.value = updatedClass
if (sel.raws && sel.raws.value) {
sel.raws.value = escapeCommas(sel.raws.value)
}
})
})
let result = parser.processSync(selectors)
return result
}
function resolveArbitraryValue(modifier, validate) {
if (!isArbitraryValue(modifier)) {
return undefined
}
let value = modifier.slice(1, -1)
if (!validate(value)) {
return undefined
}
return normalize(value)
}
function asNegativeValue(modifier, lookup = {}, validate) {
let positiveValue = lookup[modifier]
if (positiveValue !== undefined) {
return negateValue(positiveValue)
}
if (isArbitraryValue(modifier)) {
let resolved = resolveArbitraryValue(modifier, validate)
if (resolved === undefined) {
return undefined
}
return negateValue(resolved)
}
}
export function asValue(modifier, options = {}, { validate = () => true } = {}) {
let value = options.values?.[modifier]
if (value !== undefined) {
return value
}
if (options.supportsNegativeValues && modifier.startsWith('-')) {
return asNegativeValue(modifier.slice(1), options.values, validate)
}
return resolveArbitraryValue(modifier, validate)
}
function isArbitraryValue(input) {
return input.startsWith('[') && input.endsWith(']')
}
function splitAlpha(modifier) {
let slashIdx = modifier.lastIndexOf('/')
if (slashIdx === -1 || slashIdx === modifier.length - 1) {
return [modifier]
}
return [modifier.slice(0, slashIdx), modifier.slice(slashIdx + 1)]
}
export function parseColorFormat(value) {
if (typeof value === 'string' && value.includes('<alpha-value>')) {
let oldValue = value
return ({ opacityValue = 1 }) => oldValue.replace('<alpha-value>', opacityValue)
}
return value
}
export function asColor(modifier, options = {}, { tailwindConfig = {} } = {}) {
if (options.values?.[modifier] !== undefined) {
return parseColorFormat(options.values?.[modifier])
}
let [color, alpha] = splitAlpha(modifier)
if (alpha !== undefined) {
let normalizedColor =
options.values?.[color] ?? (isArbitraryValue(color) ? color.slice(1, -1) : undefined)
if (normalizedColor === undefined) {
return undefined
}
normalizedColor = parseColorFormat(normalizedColor)
if (isArbitraryValue(alpha)) {
return withAlphaValue(normalizedColor, alpha.slice(1, -1))
}
if (tailwindConfig.theme?.opacity?.[alpha] === undefined) {
return undefined
}
return withAlphaValue(normalizedColor, tailwindConfig.theme.opacity[alpha])
}
return asValue(modifier, options, { validate: validateColor })
}
export function asLookupValue(modifier, options = {}) {
return options.values?.[modifier]
}
function guess(validate) {
return (modifier, options) => {
return asValue(modifier, options, { validate })
}
}
let typeMap = {
any: asValue,
color: asColor,
url: guess(url),
image: guess(image),
length: guess(length),
percentage: guess(percentage),
position: guess(position),
lookup: asLookupValue,
'generic-name': guess(genericName),
'family-name': guess(familyName),
number: guess(number),
'line-width': guess(lineWidth),
'absolute-size': guess(absoluteSize),
'relative-size': guess(relativeSize),
shadow: guess(shadow),
}
let supportedTypes = Object.keys(typeMap)
function splitAtFirst(input, delim) {
let idx = input.indexOf(delim)
if (idx === -1) return [undefined, input]
return [input.slice(0, idx), input.slice(idx + 1)]
}
export function coerceValue(types, modifier, options, tailwindConfig) {
if (isArbitraryValue(modifier)) {
let arbitraryValue = modifier.slice(1, -1)
let [explicitType, value] = splitAtFirst(arbitraryValue, ':')
// It could be that this resolves to `url(https` which is not a valid
// identifier. We currently only support "simple" words with dashes or
// underscores. E.g.: family-name
if (!/^[\w-_]+$/g.test(explicitType)) {
value = arbitraryValue
}
//
else if (explicitType !== undefined && !supportedTypes.includes(explicitType)) {
return []
}
if (value.length > 0 && supportedTypes.includes(explicitType)) {
return [asValue(`[${value}]`, options), explicitType]
}
}
// Find first matching type
for (let type of [].concat(types)) {
let result = typeMap[type](modifier, options, { tailwindConfig })
if (result !== undefined) return [result, type]
}
return []
}
+14
View File
@@ -0,0 +1,14 @@
import parser from 'postcss-selector-parser'
export default function (prefix, selector, prependNegative = false) {
return parser((selectors) => {
selectors.walkClasses((classSelector) => {
let baseClass = classSelector.value
let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-')
classSelector.value = shouldPlaceNegativeBeforePrefix
? `-${prefix}${baseClass.slice(1)}`
: `${prefix}${baseClass}`
})
}).processSync(selector)
}
+24
View File
@@ -0,0 +1,24 @@
/**
* This function removes any uses of CSS variables used as an alpha channel
*
* This is required for selectors like `:visited` which do not allow
* changes in opacity or external control using CSS variables.
*
* @param {import('postcss').Container} container
* @param {string[]} toRemove
*/
export function removeAlphaVariables(container, toRemove) {
container.walkDecls((decl) => {
if (toRemove.includes(decl.prop)) {
decl.remove()
return
}
for (let varName of toRemove) {
if (decl.value.includes(`/ var(${varName})`)) {
decl.value = decl.value.replace(`/ var(${varName})`, '')
}
}
})
}
+283
View File
@@ -0,0 +1,283 @@
import negateValue from './negateValue'
import corePluginList from '../corePluginList'
import configurePlugins from './configurePlugins'
import defaultConfig from '../../stubs/defaultConfig.stub'
import colors from '../public/colors'
import { defaults } from './defaults'
import { toPath } from './toPath'
import { normalizeConfig } from './normalizeConfig'
import isPlainObject from './isPlainObject'
import { cloneDeep } from './cloneDeep'
import { parseColorFormat } from './pluginUtils'
import { withAlphaValue } from './withAlphaVariable'
import toColorValue from './toColorValue'
function isFunction(input) {
return typeof input === 'function'
}
function isObject(input) {
return typeof input === 'object' && input !== null
}
function mergeWith(target, ...sources) {
let customizer = sources.pop()
for (let source of sources) {
for (let k in source) {
let merged = customizer(target[k], source[k])
if (merged === undefined) {
if (isObject(target[k]) && isObject(source[k])) {
target[k] = mergeWith(target[k], source[k], customizer)
} else {
target[k] = source[k]
}
} else {
target[k] = merged
}
}
}
return target
}
const configUtils = {
colors,
negative(scale) {
// TODO: Log that this function isn't really needed anymore?
return Object.keys(scale)
.filter((key) => scale[key] !== '0')
.reduce((negativeScale, key) => {
let negativeValue = negateValue(scale[key])
if (negativeValue !== undefined) {
negativeScale[`-${key}`] = negativeValue
}
return negativeScale
}, {})
},
breakpoints(screens) {
return Object.keys(screens)
.filter((key) => typeof screens[key] === 'string')
.reduce(
(breakpoints, key) => ({
...breakpoints,
[`screen-${key}`]: screens[key],
}),
{}
)
},
}
function value(valueToResolve, ...args) {
return isFunction(valueToResolve) ? valueToResolve(...args) : valueToResolve
}
function collectExtends(items) {
return items.reduce((merged, { extend }) => {
return mergeWith(merged, extend, (mergedValue, extendValue) => {
if (mergedValue === undefined) {
return [extendValue]
}
if (Array.isArray(mergedValue)) {
return [extendValue, ...mergedValue]
}
return [extendValue, mergedValue]
})
}, {})
}
function mergeThemes(themes) {
return {
...themes.reduce((merged, theme) => defaults(merged, theme), {}),
// In order to resolve n config objects, we combine all of their `extend` properties
// into arrays instead of objects so they aren't overridden.
extend: collectExtends(themes),
}
}
function mergeExtensionCustomizer(merged, value) {
// When we have an array of objects, we do want to merge it
if (Array.isArray(merged) && isObject(merged[0])) {
return merged.concat(value)
}
// When the incoming value is an array, and the existing config is an object, prepend the existing object
if (Array.isArray(value) && isObject(value[0]) && isObject(merged)) {
return [merged, ...value]
}
// Override arrays (for example for font-families, box-shadows, ...)
if (Array.isArray(value)) {
return value
}
// Execute default behaviour
return undefined
}
function mergeExtensions({ extend, ...theme }) {
return mergeWith(theme, extend, (themeValue, extensions) => {
// The `extend` property is an array, so we need to check if it contains any functions
if (!isFunction(themeValue) && !extensions.some(isFunction)) {
return mergeWith({}, themeValue, ...extensions, mergeExtensionCustomizer)
}
return (resolveThemePath, utils) =>
mergeWith(
{},
...[themeValue, ...extensions].map((e) => value(e, resolveThemePath, utils)),
mergeExtensionCustomizer
)
})
}
/**
*
* @param {string} key
* @return {Iterable<string[] & {alpha: string | undefined}>}
*/
function* toPaths(key) {
let path = toPath(key)
if (path.length === 0) {
return
}
yield path
if (Array.isArray(key)) {
return
}
let pattern = /^(.*?)\s*\/\s*([^/]+)$/
let matches = key.match(pattern)
if (matches !== null) {
let [, prefix, alpha] = matches
let newPath = toPath(prefix)
newPath.alpha = alpha
yield newPath
}
}
function resolveFunctionKeys(object) {
// theme('colors.red.500 / 0.5') -> ['colors', 'red', '500 / 0', '5]
const resolvePath = (key, defaultValue) => {
for (const path of toPaths(key)) {
let index = 0
let val = object
while (val !== undefined && val !== null && index < path.length) {
val = val[path[index++]]
let shouldResolveAsFn =
isFunction(val) && (path.alpha === undefined || index <= path.length - 1)
val = shouldResolveAsFn ? val(resolvePath, configUtils) : val
}
if (val !== undefined) {
if (path.alpha !== undefined) {
let normalized = parseColorFormat(val)
return withAlphaValue(normalized, path.alpha, toColorValue(normalized))
}
if (isPlainObject(val)) {
return cloneDeep(val)
}
return val
}
}
return defaultValue
}
Object.assign(resolvePath, {
theme: resolvePath,
...configUtils,
})
return Object.keys(object).reduce((resolved, key) => {
resolved[key] = isFunction(object[key]) ? object[key](resolvePath, configUtils) : object[key]
return resolved
}, {})
}
function extractPluginConfigs(configs) {
let allConfigs = []
configs.forEach((config) => {
allConfigs = [...allConfigs, config]
const plugins = config?.plugins ?? []
if (plugins.length === 0) {
return
}
plugins.forEach((plugin) => {
if (plugin.__isOptionsFunction) {
plugin = plugin()
}
allConfigs = [...allConfigs, ...extractPluginConfigs([plugin?.config ?? {}])]
})
})
return allConfigs
}
function resolveCorePlugins(corePluginConfigs) {
const result = [...corePluginConfigs].reduceRight((resolved, corePluginConfig) => {
if (isFunction(corePluginConfig)) {
return corePluginConfig({ corePlugins: resolved })
}
return configurePlugins(corePluginConfig, resolved)
}, corePluginList)
return result
}
function resolvePluginLists(pluginLists) {
const result = [...pluginLists].reduceRight((resolved, pluginList) => {
return [...resolved, ...pluginList]
}, [])
return result
}
export default function resolveConfig(configs) {
let allConfigs = [
...extractPluginConfigs(configs),
{
prefix: '',
important: false,
separator: ':',
variantOrder: defaultConfig.variantOrder,
},
]
return normalizeConfig(
defaults(
{
theme: resolveFunctionKeys(
mergeExtensions(mergeThemes(allConfigs.map((t) => t?.theme ?? {})))
),
corePlugins: resolveCorePlugins(allConfigs.map((c) => c.corePlugins)),
plugins: resolvePluginLists(configs.map((c) => c?.plugins ?? [])),
},
...allConfigs
)
)
}
+55
View File
@@ -0,0 +1,55 @@
import fs from 'fs'
import path from 'path'
function isObject(value) {
return typeof value === 'object' && value !== null
}
function isEmpty(obj) {
return Object.keys(obj).length === 0
}
function isString(value) {
return typeof value === 'string' || value instanceof String
}
export default function resolveConfigPath(pathOrConfig) {
// require('tailwindcss')({ theme: ..., variants: ... })
if (isObject(pathOrConfig) && pathOrConfig.config === undefined && !isEmpty(pathOrConfig)) {
return null
}
// require('tailwindcss')({ config: 'custom-config.js' })
if (
isObject(pathOrConfig) &&
pathOrConfig.config !== undefined &&
isString(pathOrConfig.config)
) {
return path.resolve(pathOrConfig.config)
}
// require('tailwindcss')({ config: { theme: ..., variants: ... } })
if (
isObject(pathOrConfig) &&
pathOrConfig.config !== undefined &&
isObject(pathOrConfig.config)
) {
return null
}
// require('tailwindcss')('custom-config.js')
if (isString(pathOrConfig)) {
return path.resolve(pathOrConfig)
}
// require('tailwindcss')
for (const configFile of ['./tailwind.config.js', './tailwind.config.cjs']) {
try {
const configPath = path.resolve(configFile)
fs.accessSync(configPath)
return configPath
} catch (err) {}
}
return null
}
+10
View File
@@ -0,0 +1,10 @@
import postcss from 'postcss'
import cloneNodes from './cloneNodes'
export default function responsive(rules) {
return postcss
.atRule({
name: 'responsive',
})
.append(cloneNodes(Array.isArray(rules) ? rules : [rules]))
}
+71
View File
@@ -0,0 +1,71 @@
import * as regex from '../lib/regex'
/**
* This splits a string on a top-level character.
*
* Regex doesn't support recursion (at least not the JS-flavored version).
* So we have to use a tiny state machine to keep track of paren placement.
*
* Expected behavior using commas:
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
* ─┬─ ┬ ┬ ┬
* x x x ╰──────── Split because top-level
* ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens
*
* @param {string} input
* @param {string} separator
*/
export function* splitAtTopLevelOnly(input, separator) {
let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g')
let depth = 0
let lastIndex = 0
let found = false
let separatorIndex = 0
let separatorStart = 0
let separatorLength = separator.length
// Find all paren-like things & character
// And only split on commas if they're top-level
for (let match of input.matchAll(SPECIALS)) {
let matchesSeparator = match[0] === separator[separatorIndex]
let atEndOfSeparator = separatorIndex === separatorLength - 1
let matchesFullSeparator = matchesSeparator && atEndOfSeparator
if (match[0] === '(') depth++
if (match[0] === ')') depth--
if (match[0] === '[') depth++
if (match[0] === ']') depth--
if (match[0] === '{') depth++
if (match[0] === '}') depth--
if (matchesSeparator && depth === 0) {
if (separatorStart === 0) {
separatorStart = match.index
}
separatorIndex++
}
if (matchesFullSeparator && depth === 0) {
found = true
yield input.substring(lastIndex, separatorStart)
lastIndex = separatorStart + separatorLength
}
if (separatorIndex === separatorLength) {
separatorIndex = 0
separatorStart = 0
}
}
// Provide the last segment of the string if available
// Otherwise the whole string since no `char`s were found
// This mirrors the behavior of string.split()
if (found) {
yield input.substring(lastIndex)
} else {
yield input
}
}
+4
View File
@@ -0,0 +1,4 @@
export function tap(value, mutator) {
mutator(value)
return value
}
+3
View File
@@ -0,0 +1,3 @@
export default function toColorValue(maybeFunction) {
return typeof maybeFunction === 'function' ? maybeFunction({}) : maybeFunction
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Parse a path string into an array of path segments.
*
* Square bracket notation `a[b]` may be used to "escape" dots that would otherwise be interpreted as path separators.
*
* Example:
* a -> ['a']
* a.b.c -> ['a', 'b', 'c']
* a[b].c -> ['a', 'b', 'c']
* a[b.c].e.f -> ['a', 'b.c', 'e', 'f']
* a[b][c][d] -> ['a', 'b', 'c', 'd']
*
* @param {string|string[]} path
**/
export function toPath(path) {
if (Array.isArray(path)) return path
let openBrackets = path.split('[').length - 1
let closedBrackets = path.split(']').length - 1
if (openBrackets !== closedBrackets) {
throw new Error(`Path is invalid. Has unbalanced brackets: ${path}`)
}
return path.split(/\.(?![^\[]*\])|[\[\]]/g).filter(Boolean)
}
+54
View File
@@ -0,0 +1,54 @@
import postcss from 'postcss'
export default function transformThemeValue(themeSection) {
if (['fontSize', 'outline'].includes(themeSection)) {
return (value) => {
if (typeof value === 'function') value = value({})
if (Array.isArray(value)) value = value[0]
return value
}
}
if (
[
'fontFamily',
'boxShadow',
'transitionProperty',
'transitionDuration',
'transitionDelay',
'transitionTimingFunction',
'backgroundImage',
'backgroundSize',
'backgroundColor',
'cursor',
'animation',
].includes(themeSection)
) {
return (value) => {
if (typeof value === 'function') value = value({})
if (Array.isArray(value)) value = value.join(', ')
return value
}
}
// For backwards compatibility reasons, before we switched to underscores
// instead of commas for arbitrary values.
if (['gridTemplateColumns', 'gridTemplateRows', 'objectPosition'].includes(themeSection)) {
return (value) => {
if (typeof value === 'function') value = value({})
if (typeof value === 'string') value = postcss.list.comma(value).join(' ')
return value
}
}
return (value, opts = {}) => {
if (typeof value === 'function') {
value = value(opts)
}
return value
}
}
+13
View File
@@ -0,0 +1,13 @@
import log from './log'
export function validateConfig(config) {
if (config.content.files.length === 0) {
log.warn('content-problems', [
'The `content` option in your Tailwind CSS configuration is missing or empty.',
'Configure your content sources or your generated CSS will be missing styles.',
'https://tailwindcss.com/docs/content-configuration',
])
}
return config
}
+49
View File
@@ -0,0 +1,49 @@
import { parseColor, formatColor } from './color'
export function withAlphaValue(color, alphaValue, defaultValue) {
if (typeof color === 'function') {
return color({ opacityValue: alphaValue })
}
let parsed = parseColor(color, { loose: true })
if (parsed === null) {
return defaultValue
}
return formatColor({ ...parsed, alpha: alphaValue })
}
export default function withAlphaVariable({ color, property, variable }) {
let properties = [].concat(property)
if (typeof color === 'function') {
return {
[variable]: '1',
...Object.fromEntries(
properties.map((p) => {
return [p, color({ opacityVariable: variable, opacityValue: `var(${variable})` })]
})
),
}
}
const parsed = parseColor(color)
if (parsed === null) {
return Object.fromEntries(properties.map((p) => [p, color]))
}
if (parsed.alpha !== undefined) {
// Has an alpha value, return color as-is
return Object.fromEntries(properties.map((p) => [p, color]))
}
return {
[variable]: '1',
...Object.fromEntries(
properties.map((p) => {
return [p, formatColor({ ...parsed, alpha: `var(${variable})` })]
})
),
}
}