Spaces:
Sleeping
Sleeping
| 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) | |
| }) | |
| } | |
| } | |