Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions __tests__/themeFunction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'); }
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
130 changes: 119 additions & 11 deletions src/lib/evaluateTailwindFunctions.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import _ from 'lodash'
import functions from 'postcss-functions'
import didYouMean from 'didyoumean'

const themeTransforms = {
fontSize(value) {
Expand All @@ -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)
}
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down