Skip to content

Commit

Permalink
feat(tailwind-plugins): add spark theme plugin
Browse files Browse the repository at this point in the history
Add Spark theme plugin

#411
  • Loading branch information
acd02 committed Mar 17, 2023
1 parent e7b46df commit 7009c51
Show file tree
Hide file tree
Showing 8 changed files with 412 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/utils/tailwind-plugins/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const animations = require('./animations')
const sizings = require('./sizings')
const sparkTheme = require('./spark-theme')

module.exports = {
animations,
sizings,
sparkTheme,
}
3 changes: 3 additions & 0 deletions packages/utils/tailwind-plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"access": "public"
},
"main": "index.js",
"dependencies": {
"@spark-ui/theme-utils": "^2.9.0"
},
"peerDependencies": {
"tailwindcss": "4.0.0"
},
Expand Down
15 changes: 15 additions & 0 deletions packages/utils/tailwind-plugins/spark-theme/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const tailwindCategoryKeys = {
colors: 'colors',
fontSize: 'fontSize',
screens: 'screens',
}

const unassignedColors = {
inherit: 'inherit',
current: 'currentColor',
transparent: 'transparent',
}

const DEFAULT_KEY = 'DEFAULT'

module.exports = { tailwindCategoryKeys, unassignedColors, DEFAULT_KEY }
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { DEFAULT_KEY } = require('./constants')
const { hexRgb } = require('./hexRgb')

const {
doubleHyphensRegex,
getRemEquivalentValue,
isHex,
isObject,
isStringOrNumber,
toKebabCase,
} = require('./utils')

function getCSSVariableDeclarations(_theme, htmlFontSize) {
const CSSVariableObj = {}

function traverse(theme, paths = []) {
Object.entries(theme).forEach(([key, value]) => {
if (isObject(value)) {
return traverse(value, paths.concat(key))
}

if (isStringOrNumber(value)) {
const getFormattedValue = () => {
if (isHex(value)) {
const { red, green, blue } = hexRgb(value)

return `${red} ${green} ${blue}`
}

if (/rem$/gi.test(value)) {
return getRemEquivalentValue(value, htmlFontSize)
}

return value
}

CSSVariableObj[
`--${[...paths, key === DEFAULT_KEY ? key.toLowerCase() : key]
.map(toKebabCase)
.join('-')
.replace(doubleHyphensRegex, '-')}`
] = getFormattedValue()
}
})
}

traverse(_theme)

return CSSVariableObj
}

module.exports = { getCSSVariableDeclarations }
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { DEFAULT_KEY, tailwindCategoryKeys, unassignedColors } = require('./constants')
const {
doubleHyphensRegex,
hasNumber,
isAlphanumericWithLeadingLetter,
isCamelCase,
isHex,
isObject,
isStringOrNumber,
toKebabCase,
} = require('./utils')

function getCSSVariableReferences(_theme) {
const themeCpy = JSON.parse(JSON.stringify(_theme))

const { fontSize, colors, screens } = tailwindCategoryKeys

/* eslint-disable complexity */
function traverse(theme, paths = []) {
Object.entries(theme).forEach(([key, value]) => {
// 👀 see: https://tailwindcss.com/docs/font-size#providing-a-default-line-height
if (isObject(value) && !paths.length && key === fontSize) {
Object.keys(value).forEach(k => {
const prefix = toKebabCase(fontSize)
if (isStringOrNumber(value[k])) {
theme[key][k] = `var(--${prefix}-${k})`

return
}

const kebabedKey = isCamelCase(k) || hasNumber(k) ? toKebabCase(k) : k

if (kebabedKey !== k) {
const tmp = theme[key][k]
delete theme[key][k]
theme[key][kebabedKey] = tmp
}

theme[key][kebabedKey] = [
`var(--${prefix}-${kebabedKey}-font-size)`,
{
...(value[kebabedKey].lineHeight && {
lineHeight: `var(--${prefix}-${kebabedKey}-line-height)`,
}),
...(value[kebabedKey].letterSpacing && {
letterSpacing: `var(--${prefix}-${kebabedKey}-letter-spacing)`,
}),
...(value[kebabedKey].fontWeight && {
fontWeight: `var(--${prefix}-${kebabedKey}-font-weight)`,
}),
},
]
})

return
}

if (isObject(value)) {
Object.keys(value).forEach(k => {
if (k === DEFAULT_KEY) {
return
}

if (!isObject(value[k]) && !isCamelCase(k)) {
return
}

const tmp = value[k]
delete value[k]
value[toKebabCase(k)] = tmp
})

return traverse(value, paths.concat(key))
}

if (isStringOrNumber(value)) {
const rootPath = paths[0] || ''
const isScreenValue = rootPath.includes(screens)
const isColorValue = rootPath.includes(colors)

const formattedValue = (() => {
if (isColorValue && isHex(value)) {
return `rgb(var(--${paths.join('-')}-${key}) / <alpha-value>)`
}
if (isScreenValue) {
return String(value).toLowerCase()
}

return `var(--${paths.join('-')}-${key.toLowerCase()})`
})()

const formattedKey = isAlphanumericWithLeadingLetter(key) ? toKebabCase(key) : key

if (formattedKey !== key) {
delete theme[key]
}

theme[formattedKey] = isScreenValue
? formattedValue
: toKebabCase(formattedValue).replace(doubleHyphensRegex, '-')
}
})
}

traverse(themeCpy)

return { ...themeCpy, colors: { ...themeCpy.colors, ...unassignedColors } }
}

module.exports = { getCSSVariableReferences }
51 changes: 51 additions & 0 deletions packages/utils/tailwind-plugins/spark-theme/hexRgb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// see 👀: https://github.com/sindresorhus/hex-rgb/blob/main/index.js

const hexCharacters = 'a-f\\d'
const match3or4Hex = `#?[${hexCharacters}]{3}[${hexCharacters}]?`
const match6or8Hex = `#?[${hexCharacters}]{6}([${hexCharacters}]{2})?`
const nonHexChars = new RegExp(`[^#${hexCharacters}]`, 'gi')
const validHexSize = new RegExp(`^${match3or4Hex}$|^${match6or8Hex}$`, 'i')

/* eslint-disable complexity */
function hexRgb(hex, options = {}) {
if (typeof hex !== 'string' || nonHexChars.test(hex) || !validHexSize.test(hex)) {
throw new TypeError('Expected a valid hex string')
}

hex = hex.replace(/^#/, '')
let alphaFromHex = 1

if (hex.length === 8) {
alphaFromHex = Number.parseInt(hex.slice(6, 8), 16) / 255
hex = hex.slice(0, 6)
}

if (hex.length === 4) {
alphaFromHex = Number.parseInt(hex.slice(3, 4).repeat(2), 16) / 255
hex = hex.slice(0, 3)
}

if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
}

const number = Number.parseInt(hex, 16)
const red = number >> 16
const green = (number >> 8) & 255
const blue = number & 255
const alpha = typeof options.alpha === 'number' ? options.alpha : alphaFromHex

if (options.format === 'array') {
return [red, green, blue, alpha]
}

if (options.format === 'css') {
const alphaString = alpha === 1 ? '' : ` / ${Number((alpha * 100).toFixed(2))}%`

return `rgb(${red} ${green} ${blue}${alphaString})`
}

return { red, green, blue, alpha }
}

module.exports = { hexRgb }
94 changes: 94 additions & 0 deletions packages/utils/tailwind-plugins/spark-theme/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { getCSSVariableDeclarations } = require('./getCSSVariableDeclarations')
const { getCSSVariableReferences } = require('./getCSSVariableReferences')
const { retrieveArrayDifferences, getAllObjectKeys } = require('./utils')

const themeUtils = require('@spark-ui/theme-utils')
const plugin = require('tailwindcss/plugin')

const missingDefaultThemeErrorMsg =
'A default theme is required. Please ensure that the "themes" object passed to this plugin includes a "default" key containing your default theme.'

const additionalItemsErrorMsg = (themeLabel, keys) =>
`The following keys: ${JSON.stringify(
keys
)} do not adhere to our Spark Theme interface and should be removed from the ${themeLabel} theme`

const missingItemsErrorMsg = (themeLabel, keys) =>
`The following keys: ${JSON.stringify(
keys
)} are missing from the ${themeLabel} theme, but required to comply with our Spark Theme interface`

module.exports = plugin.withOptions(
/**
* @typedef {Object} Options
* @property {Object} options.themes - An object containing your themes where each key corresponds to a data-theme attribute value.
* @property {number} htmlFontSize The base font size of your app.
*/

/**
* @param {Object} options The options for the plugin.
* @param {Object} [options.themes={}] An object containing your themes where each key corresponds to a data-theme attribute value.
* @param {string} [options.htmlFontSize=16] The base font size to use to properly compute rem values.
* @returns {Function} The PostCSS plugin function.
*/
options =>
({ addBase }) => {
const opts = options || {
themes: {},
}

const { htmlFontSize = 16, themes } = opts

if (!themes.default) {
throw new Error(missingDefaultThemeErrorMsg)
}

const { missingItems, additionalItems } = retrieveArrayDifferences({
ref: getAllObjectKeys(themeUtils.defaultTheme),
comp: getAllObjectKeys(themes.default),
})

if (missingItems.length) {
throw new Error(missingItemsErrorMsg('default', missingItems))
}
if (additionalItems.length) {
throw new Error(additionalItemsErrorMsg('default', additionalItems))
}

addBase({
':root': getCSSVariableDeclarations(themes.default, htmlFontSize),
})

Object.entries(themes).forEach(([key, value]) => {
const { missingItems, additionalItems } = retrieveArrayDifferences({
ref: getAllObjectKeys(themeUtils.defaultTheme),
comp: getAllObjectKeys(value),
})

if (missingItems.length) {
throw new Error(missingItemsErrorMsg(key, missingItems))
}
if (additionalItems.length) {
throw new Error(additionalItemsErrorMsg(key, additionalItems))
}

addBase({
[`[data-theme="${key}"]`]: getCSSVariableDeclarations(value, htmlFontSize),
})
})
},
options => {
const opts = options || {
themes: {},
}

const { themes } = opts

if (!themes.default) {
throw new Error(missingDefaultThemeErrorMsg)
}

return { theme: getCSSVariableReferences(themes.default) }
}
)
Loading

0 comments on commit 7009c51

Please sign in to comment.