You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			273 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			273 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			JavaScript
		
	
| import dlv from 'dlv'
 | |
| import didYouMean from 'didyoumean'
 | |
| import transformThemeValue from '../util/transformThemeValue'
 | |
| import parseValue from '../value-parser/index'
 | |
| 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'
 | |
| import log from '../util/log'
 | |
| 
 | |
| 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) {
 | |
|   let hasAnyFn = Object.keys(functions).some((fn) => input.includes(`${fn}(`))
 | |
|   if (!hasAnyFn) return input
 | |
| 
 | |
|   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 (context) {
 | |
|   let config = context.tailwindConfig
 | |
| 
 | |
|   let functions = {
 | |
|     theme: (node, path, ...defaultValue) => {
 | |
|       let { isValid, value, error, alpha } = resolvePath(
 | |
|         config,
 | |
|         path,
 | |
|         defaultValue.length ? defaultValue : undefined
 | |
|       )
 | |
| 
 | |
|       if (!isValid) {
 | |
|         let parentNode = node.parent
 | |
|         let candidate = parentNode?.raws.tailwind?.candidate
 | |
| 
 | |
|         if (parentNode && candidate !== undefined) {
 | |
|           // Remove this utility from any caches
 | |
|           context.markInvalidUtilityNode(parentNode)
 | |
| 
 | |
|           // Remove the CSS node from the markup
 | |
|           parentNode.remove()
 | |
| 
 | |
|           // Show a warning
 | |
|           log.warn('invalid-theme-key-in-class', [
 | |
|             `The utility \`${candidate}\` contains an invalid theme value and was not generated.`,
 | |
|           ])
 | |
| 
 | |
|           return
 | |
|         }
 | |
| 
 | |
|         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)
 | |
|     })
 | |
|   }
 | |
| }
 |