Add theme helper function errors (#2615)

* feat: throw when theme does not have requested path (#1799)

* Add suggestions to theme function errors

Co-authored-by: Jason Quense <monastic.panic@gmail.com>
This commit is contained in:
Brad Cornes 2020-10-19 13:12:14 +01:00 • committed by GitHub
parent d8841ffa43
commit 5bf4699a35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 247 additions and 12 deletions

View file

@ -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'); }

View file

@ -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",

View file

@ -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
}
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('.')
function findClosestExistingPath(theme, path) {
const parts = _.toPath(path)
do {
parts.pop()
return _.get(themeTransforms, themeSection, defaultTransform)(value)
})
},
},
})
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 (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)
}

View file

@ -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"