-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(tailwind-plugins): add spark theme plugin
Add Spark theme plugin #411
- Loading branch information
Showing
8 changed files
with
412 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
53 changes: 53 additions & 0 deletions
53
packages/utils/tailwind-plugins/spark-theme/getCSSVariableDeclarations.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
111 changes: 111 additions & 0 deletions
111
packages/utils/tailwind-plugins/spark-theme/getCSSVariableReferences.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) } | ||
} | ||
) |
Oops, something went wrong.