diff --git a/__tests__/themeFunction.test.js b/__tests__/themeFunction.test.js index aa6823350f1c..252bb73e0e80 100644 --- a/__tests__/themeFunction.test.js +++ b/__tests__/themeFunction.test.js @@ -110,6 +110,127 @@ test('an unquoted list is valid as a default value', () => { }) }) +test('a missing root theme value throws', () => { + const input = ` + .heading { color: theme('colours.gray.100'); } + ` + + return expect( + run(input, { + theme: { + colors: { + yellow: '#f7cc50', + }, + }, + }) + ).rejects.toThrowError( + `'colours.gray.100' does not exist in your theme config. Your theme has the following top-level keys: 'colors'` + ) +}) + +test('a missing nested theme property throws', () => { + const input = ` + .heading { color: theme('colors.red'); } + ` + + return expect( + run(input, { + theme: { + colors: { + blue: 'blue', + yellow: '#f7cc50', + }, + }, + }) + ).rejects.toThrowError( + `'colors.red' does not exist in your theme config. 'colors' has the following valid keys: 'blue', 'yellow'` + ) +}) + +test('a missing nested theme property with a close alternative throws with a suggestion', () => { + const input = ` + .heading { color: theme('colors.yellw'); } + ` + + return expect( + run(input, { + theme: { + colors: { + yellow: '#f7cc50', + }, + }, + }) + ).rejects.toThrowError( + `'colors.yellw' does not exist in your theme config. Did you mean 'colors.yellow'?` + ) +}) + +test('a path through a non-object throws', () => { + const input = ` + .heading { color: theme('colors.yellow.100'); } + ` + + return expect( + run(input, { + theme: { + colors: { + yellow: '#f7cc50', + }, + }, + }) + ).rejects.toThrowError( + `'colors.yellow.100' does not exist in your theme config. 'colors.yellow' is not an object.` + ) +}) + +test('a path which exists but is not a string throws', () => { + const input = ` + .heading { color: theme('colors.yellow'); } + ` + + return expect( + run(input, { + theme: { + colors: { + yellow: Symbol(), + }, + }, + }) + ).rejects.toThrowError(`'colors.yellow' was found but does not resolve to a string.`) +}) + +test('a path which exists but is invalid throws', () => { + const input = ` + .heading { color: theme('colors'); } + ` + + return expect( + run(input, { + theme: { + colors: {}, + }, + }) + ).rejects.toThrowError(`'colors' was found but does not resolve to a string.`) +}) + +test('a path which is an object throws with a suggested key', () => { + const input = ` + .heading { color: theme('colors'); } + ` + + return expect( + run(input, { + theme: { + colors: { + yellow: '#f7cc50', + }, + }, + }) + ).rejects.toThrowError( + `'colors' was found but does not resolve to a string. Did you mean something like 'colors.yellow'?` + ) +}) + test('array values are joined by default', () => { const input = ` .heading { font-family: theme('fontFamily.sans'); } diff --git a/package.json b/package.json index 066012b85e51..eced072d8941 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "chalk": "^4.1.0", "color": "^3.1.3", "detective": "^5.2.0", + "didyoumean": "^1.2.1", "fs-extra": "^9.0.1", "html-tags": "^3.1.0", "lodash": "^4.17.20", diff --git a/src/lib/evaluateTailwindFunctions.js b/src/lib/evaluateTailwindFunctions.js index 15680ef6c868..04eb6cfbeb8d 100644 --- a/src/lib/evaluateTailwindFunctions.js +++ b/src/lib/evaluateTailwindFunctions.js @@ -1,5 +1,6 @@ import _ from 'lodash' import functions from 'postcss-functions' +import didYouMean from 'didyoumean' const themeTransforms = { fontSize(value) { @@ -11,17 +12,124 @@ function defaultTransform(value) { return Array.isArray(value) ? value.join(', ') : value } +function findClosestExistingPath(theme, path) { + const parts = _.toPath(path) + do { + parts.pop() + + if (_.hasIn(theme, parts)) 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) { + const pathString = Array.isArray(path) ? pathToString(path) : _.trim(path, `'"`) + const pathSegments = Array.isArray(path) ? path : _.toPath(pathString) + const value = _.get(config.theme, pathString, defaultValue) + + if (typeof value === 'undefined') { + let error = `'${pathString}' does not exist in your theme config.` + const parentSegments = pathSegments.slice(0, -1) + const parentValue = _.get(config.theme, parentSegments) + + if (_.isObject(parentValue)) { + const validKeys = Object.keys(parentValue).filter( + (key) => validatePath(config, [...parentSegments, key]).isValid + ) + const suggestion = didYouMean(_.last(pathSegments), 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 = _.get(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' || + 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: _.get(themeTransforms, themeSection, defaultTransform)(value), + } +} + export default function (config) { - return functions({ - functions: { - theme: (path, ...defaultValue) => { - const trimmedPath = _.trim(path, `'"`) - return _.thru(_.get(config.theme, trimmedPath, defaultValue), (value) => { - const [themeSection] = trimmedPath.split('.') - - return _.get(themeTransforms, themeSection, defaultTransform)(value) - }) + return (root) => + functions({ + functions: { + theme: (path, ...defaultValue) => { + return _.thru( + validatePath(config, path, defaultValue.length ? defaultValue : undefined), + ({ isValid, value, error }) => { + if (isValid) return value + throw root.error(error) + } + ) + }, }, - }, - }) + })(root) } diff --git a/yarn.lock b/yarn.lock index e5d3ea757d68..f2b9260211b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2158,6 +2158,11 @@ detective@^5.2.0: defined "^1.0.0" minimist "^1.1.1" +didyoumean@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.1.tgz#e92edfdada6537d484d73c0172fd1eba0c4976ff" + integrity sha1-6S7f2tplN9SE1zwBcv0eugxJdv8= + diff-sequences@^26.5.0: version "26.5.0" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd"