| 1 | import dlv from 'dlv'
|
|---|
| 2 | import didYouMean from 'didyoumean'
|
|---|
| 3 | import transformThemeValue from '../util/transformThemeValue'
|
|---|
| 4 | import parseValue from '../value-parser/index'
|
|---|
| 5 | import { normalizeScreens } from '../util/normalizeScreens'
|
|---|
| 6 | import buildMediaQuery from '../util/buildMediaQuery'
|
|---|
| 7 | import { toPath } from '../util/toPath'
|
|---|
| 8 | import { withAlphaValue } from '../util/withAlphaVariable'
|
|---|
| 9 | import { parseColorFormat } from '../util/pluginUtils'
|
|---|
| 10 | import log from '../util/log'
|
|---|
| 11 |
|
|---|
| 12 | function isObject(input) {
|
|---|
| 13 | return typeof input === 'object' && input !== null
|
|---|
| 14 | }
|
|---|
| 15 |
|
|---|
| 16 | function findClosestExistingPath(theme, path) {
|
|---|
| 17 | let parts = toPath(path)
|
|---|
| 18 | do {
|
|---|
| 19 | parts.pop()
|
|---|
| 20 |
|
|---|
| 21 | if (dlv(theme, parts) !== undefined) break
|
|---|
| 22 | } while (parts.length)
|
|---|
| 23 |
|
|---|
| 24 | return parts.length ? parts : undefined
|
|---|
| 25 | }
|
|---|
| 26 |
|
|---|
| 27 | function pathToString(path) {
|
|---|
| 28 | if (typeof path === 'string') return path
|
|---|
| 29 | return path.reduce((acc, cur, i) => {
|
|---|
| 30 | if (cur.includes('.')) return `${acc}[${cur}]`
|
|---|
| 31 | return i === 0 ? cur : `${acc}.${cur}`
|
|---|
| 32 | }, '')
|
|---|
| 33 | }
|
|---|
| 34 |
|
|---|
| 35 | function list(items) {
|
|---|
| 36 | return items.map((key) => `'${key}'`).join(', ')
|
|---|
| 37 | }
|
|---|
| 38 |
|
|---|
| 39 | function listKeys(obj) {
|
|---|
| 40 | return list(Object.keys(obj))
|
|---|
| 41 | }
|
|---|
| 42 |
|
|---|
| 43 | function validatePath(config, path, defaultValue, themeOpts = {}) {
|
|---|
| 44 | const pathString = Array.isArray(path) ? pathToString(path) : path.replace(/^['"]+|['"]+$/g, '')
|
|---|
| 45 | const pathSegments = Array.isArray(path) ? path : toPath(pathString)
|
|---|
| 46 | const value = dlv(config.theme, pathSegments, defaultValue)
|
|---|
| 47 |
|
|---|
| 48 | if (value === undefined) {
|
|---|
| 49 | let error = `'${pathString}' does not exist in your theme config.`
|
|---|
| 50 | const parentSegments = pathSegments.slice(0, -1)
|
|---|
| 51 | const parentValue = dlv(config.theme, parentSegments)
|
|---|
| 52 |
|
|---|
| 53 | if (isObject(parentValue)) {
|
|---|
| 54 | const validKeys = Object.keys(parentValue).filter(
|
|---|
| 55 | (key) => validatePath(config, [...parentSegments, key]).isValid
|
|---|
| 56 | )
|
|---|
| 57 | const suggestion = didYouMean(pathSegments[pathSegments.length - 1], validKeys)
|
|---|
| 58 | if (suggestion) {
|
|---|
| 59 | error += ` Did you mean '${pathToString([...parentSegments, suggestion])}'?`
|
|---|
| 60 | } else if (validKeys.length > 0) {
|
|---|
| 61 | error += ` '${pathToString(parentSegments)}' has the following valid keys: ${list(
|
|---|
| 62 | validKeys
|
|---|
| 63 | )}`
|
|---|
| 64 | }
|
|---|
| 65 | } else {
|
|---|
| 66 | const closestPath = findClosestExistingPath(config.theme, pathString)
|
|---|
| 67 | if (closestPath) {
|
|---|
| 68 | const closestValue = dlv(config.theme, closestPath)
|
|---|
| 69 | if (isObject(closestValue)) {
|
|---|
| 70 | error += ` '${pathToString(closestPath)}' has the following keys: ${listKeys(
|
|---|
| 71 | closestValue
|
|---|
| 72 | )}`
|
|---|
| 73 | } else {
|
|---|
| 74 | error += ` '${pathToString(closestPath)}' is not an object.`
|
|---|
| 75 | }
|
|---|
| 76 | } else {
|
|---|
| 77 | error += ` Your theme has the following top-level keys: ${listKeys(config.theme)}`
|
|---|
| 78 | }
|
|---|
| 79 | }
|
|---|
| 80 |
|
|---|
| 81 | return {
|
|---|
| 82 | isValid: false,
|
|---|
| 83 | error,
|
|---|
| 84 | }
|
|---|
| 85 | }
|
|---|
| 86 |
|
|---|
| 87 | if (
|
|---|
| 88 | !(
|
|---|
| 89 | typeof value === 'string' ||
|
|---|
| 90 | typeof value === 'number' ||
|
|---|
| 91 | typeof value === 'function' ||
|
|---|
| 92 | value instanceof String ||
|
|---|
| 93 | value instanceof Number ||
|
|---|
| 94 | Array.isArray(value)
|
|---|
| 95 | )
|
|---|
| 96 | ) {
|
|---|
| 97 | let error = `'${pathString}' was found but does not resolve to a string.`
|
|---|
| 98 |
|
|---|
| 99 | if (isObject(value)) {
|
|---|
| 100 | let validKeys = Object.keys(value).filter(
|
|---|
| 101 | (key) => validatePath(config, [...pathSegments, key]).isValid
|
|---|
| 102 | )
|
|---|
| 103 | if (validKeys.length) {
|
|---|
| 104 | error += ` Did you mean something like '${pathToString([...pathSegments, validKeys[0]])}'?`
|
|---|
| 105 | }
|
|---|
| 106 | }
|
|---|
| 107 |
|
|---|
| 108 | return {
|
|---|
| 109 | isValid: false,
|
|---|
| 110 | error,
|
|---|
| 111 | }
|
|---|
| 112 | }
|
|---|
| 113 |
|
|---|
| 114 | const [themeSection] = pathSegments
|
|---|
| 115 |
|
|---|
| 116 | return {
|
|---|
| 117 | isValid: true,
|
|---|
| 118 | value: transformThemeValue(themeSection)(value, themeOpts),
|
|---|
| 119 | }
|
|---|
| 120 | }
|
|---|
| 121 |
|
|---|
| 122 | function extractArgs(node, vNodes, functions) {
|
|---|
| 123 | vNodes = vNodes.map((vNode) => resolveVNode(node, vNode, functions))
|
|---|
| 124 |
|
|---|
| 125 | let args = ['']
|
|---|
| 126 |
|
|---|
| 127 | for (let vNode of vNodes) {
|
|---|
| 128 | if (vNode.type === 'div' && vNode.value === ',') {
|
|---|
| 129 | args.push('')
|
|---|
| 130 | } else {
|
|---|
| 131 | args[args.length - 1] += parseValue.stringify(vNode)
|
|---|
| 132 | }
|
|---|
| 133 | }
|
|---|
| 134 |
|
|---|
| 135 | return args
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | function resolveVNode(node, vNode, functions) {
|
|---|
| 139 | if (vNode.type === 'function' && functions[vNode.value] !== undefined) {
|
|---|
| 140 | let args = extractArgs(node, vNode.nodes, functions)
|
|---|
| 141 | vNode.type = 'word'
|
|---|
| 142 | vNode.value = functions[vNode.value](node, ...args)
|
|---|
| 143 | }
|
|---|
| 144 |
|
|---|
| 145 | return vNode
|
|---|
| 146 | }
|
|---|
| 147 |
|
|---|
| 148 | function resolveFunctions(node, input, functions) {
|
|---|
| 149 | let hasAnyFn = Object.keys(functions).some((fn) => input.includes(`${fn}(`))
|
|---|
| 150 | if (!hasAnyFn) return input
|
|---|
| 151 |
|
|---|
| 152 | return parseValue(input)
|
|---|
| 153 | .walk((vNode) => {
|
|---|
| 154 | resolveVNode(node, vNode, functions)
|
|---|
| 155 | })
|
|---|
| 156 | .toString()
|
|---|
| 157 | }
|
|---|
| 158 |
|
|---|
| 159 | let nodeTypePropertyMap = {
|
|---|
| 160 | atrule: 'params',
|
|---|
| 161 | decl: 'value',
|
|---|
| 162 | }
|
|---|
| 163 |
|
|---|
| 164 | /**
|
|---|
| 165 | * @param {string} path
|
|---|
| 166 | * @returns {Iterable<[path: string, alpha: string|undefined]>}
|
|---|
| 167 | */
|
|---|
| 168 | function* toPaths(path) {
|
|---|
| 169 | // Strip quotes from beginning and end of string
|
|---|
| 170 | // This allows the alpha value to be present inside of quotes
|
|---|
| 171 | path = path.replace(/^['"]+|['"]+$/g, '')
|
|---|
| 172 |
|
|---|
| 173 | let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]+))$/)
|
|---|
| 174 | let alpha = undefined
|
|---|
| 175 |
|
|---|
| 176 | yield [path, undefined]
|
|---|
| 177 |
|
|---|
| 178 | if (matches) {
|
|---|
| 179 | path = matches[1]
|
|---|
| 180 | alpha = matches[2]
|
|---|
| 181 |
|
|---|
| 182 | yield [path, alpha]
|
|---|
| 183 | }
|
|---|
| 184 | }
|
|---|
| 185 |
|
|---|
| 186 | /**
|
|---|
| 187 | *
|
|---|
| 188 | * @param {any} config
|
|---|
| 189 | * @param {string} path
|
|---|
| 190 | * @param {any} defaultValue
|
|---|
| 191 | */
|
|---|
| 192 | function resolvePath(config, path, defaultValue) {
|
|---|
| 193 | const results = Array.from(toPaths(path)).map(([path, alpha]) => {
|
|---|
| 194 | return Object.assign(validatePath(config, path, defaultValue, { opacityValue: alpha }), {
|
|---|
| 195 | resolvedPath: path,
|
|---|
| 196 | alpha,
|
|---|
| 197 | })
|
|---|
| 198 | })
|
|---|
| 199 |
|
|---|
| 200 | return results.find((result) => result.isValid) ?? results[0]
|
|---|
| 201 | }
|
|---|
| 202 |
|
|---|
| 203 | export default function (context) {
|
|---|
| 204 | let config = context.tailwindConfig
|
|---|
| 205 |
|
|---|
| 206 | let functions = {
|
|---|
| 207 | theme: (node, path, ...defaultValue) => {
|
|---|
| 208 | let { isValid, value, error, alpha } = resolvePath(
|
|---|
| 209 | config,
|
|---|
| 210 | path,
|
|---|
| 211 | defaultValue.length ? defaultValue : undefined
|
|---|
| 212 | )
|
|---|
| 213 |
|
|---|
| 214 | if (!isValid) {
|
|---|
| 215 | let parentNode = node.parent
|
|---|
| 216 | let candidate = parentNode?.raws.tailwind?.candidate
|
|---|
| 217 |
|
|---|
| 218 | if (parentNode && candidate !== undefined) {
|
|---|
| 219 | // Remove this utility from any caches
|
|---|
| 220 | context.markInvalidUtilityNode(parentNode)
|
|---|
| 221 |
|
|---|
| 222 | // Remove the CSS node from the markup
|
|---|
| 223 | parentNode.remove()
|
|---|
| 224 |
|
|---|
| 225 | // Show a warning
|
|---|
| 226 | log.warn('invalid-theme-key-in-class', [
|
|---|
| 227 | `The utility \`${candidate}\` contains an invalid theme value and was not generated.`,
|
|---|
| 228 | ])
|
|---|
| 229 |
|
|---|
| 230 | return
|
|---|
| 231 | }
|
|---|
| 232 |
|
|---|
| 233 | throw node.error(error)
|
|---|
| 234 | }
|
|---|
| 235 |
|
|---|
| 236 | let maybeColor = parseColorFormat(value)
|
|---|
| 237 | let isColorFunction = maybeColor !== undefined && typeof maybeColor === 'function'
|
|---|
| 238 |
|
|---|
| 239 | if (alpha !== undefined || isColorFunction) {
|
|---|
| 240 | if (alpha === undefined) {
|
|---|
| 241 | alpha = 1.0
|
|---|
| 242 | }
|
|---|
| 243 |
|
|---|
| 244 | value = withAlphaValue(maybeColor, alpha, maybeColor)
|
|---|
| 245 | }
|
|---|
| 246 |
|
|---|
| 247 | return value
|
|---|
| 248 | },
|
|---|
| 249 | screen: (node, screen) => {
|
|---|
| 250 | screen = screen.replace(/^['"]+/g, '').replace(/['"]+$/g, '')
|
|---|
| 251 | let screens = normalizeScreens(config.theme.screens)
|
|---|
| 252 | let screenDefinition = screens.find(({ name }) => name === screen)
|
|---|
| 253 |
|
|---|
| 254 | if (!screenDefinition) {
|
|---|
| 255 | throw node.error(`The '${screen}' screen does not exist in your theme.`)
|
|---|
| 256 | }
|
|---|
| 257 |
|
|---|
| 258 | return buildMediaQuery(screenDefinition)
|
|---|
| 259 | },
|
|---|
| 260 | }
|
|---|
| 261 | return (root) => {
|
|---|
| 262 | root.walk((node) => {
|
|---|
| 263 | let property = nodeTypePropertyMap[node.type]
|
|---|
| 264 |
|
|---|
| 265 | if (property === undefined) {
|
|---|
| 266 | return
|
|---|
| 267 | }
|
|---|
| 268 |
|
|---|
| 269 | node[property] = resolveFunctions(node, node[property], functions)
|
|---|
| 270 | })
|
|---|
| 271 | }
|
|---|
| 272 | }
|
|---|