@spark-ui/tailwind-plugins 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/index.js +2 -0
- package/package.json +5 -2
- package/spark-theme/constants.js +15 -0
- package/spark-theme/getCSSVariableDeclarations.js +53 -0
- package/spark-theme/getCSSVariableReferences.js +111 -0
- package/spark-theme/hexRgb.js +51 -0
- package/spark-theme/index.js +98 -0
- package/spark-theme/utils.js +83 -0
package/CHANGELOG.md
CHANGED
@@ -3,6 +3,16 @@
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
5
5
|
|
6
|
+
# [2.1.0](https://github.com/adevinta/spark/compare/@spark-ui/tailwind-plugins@2.0.0...@spark-ui/tailwind-plugins@2.1.0) (2023-03-17)
|
7
|
+
|
8
|
+
### Bug Fixes
|
9
|
+
|
10
|
+
- add early return ([3cc0916](https://github.com/adevinta/spark/commit/3cc0916d4fe1aafd76814646b804be45b8870f34)), closes [#411](https://github.com/adevinta/spark/issues/411)
|
11
|
+
|
12
|
+
### Features
|
13
|
+
|
14
|
+
- **tailwind-plugins:** add spark theme plugin ([7009c51](https://github.com/adevinta/spark/commit/7009c513ab37b1118b89d7bada365156fb85f362)), closes [#411](https://github.com/adevinta/spark/issues/411)
|
15
|
+
|
6
16
|
# [2.0.0](https://github.com/adevinta/spark/compare/@spark-ui/tailwind-plugins@1.1.2...@spark-ui/tailwind-plugins@2.0.0) (2023-03-15)
|
7
17
|
|
8
18
|
### Features
|
package/index.js
CHANGED
package/package.json
CHANGED
@@ -1,11 +1,14 @@
|
|
1
1
|
{
|
2
2
|
"name": "@spark-ui/tailwind-plugins",
|
3
|
-
"version": "2.
|
3
|
+
"version": "2.1.0",
|
4
4
|
"description": "Spark Tailwind plugins",
|
5
5
|
"publishConfig": {
|
6
6
|
"access": "public"
|
7
7
|
},
|
8
8
|
"main": "index.js",
|
9
|
+
"dependencies": {
|
10
|
+
"@spark-ui/theme-utils": "^2.9.0"
|
11
|
+
},
|
9
12
|
"peerDependencies": {
|
10
13
|
"tailwindcss": "4.0.0"
|
11
14
|
},
|
@@ -14,5 +17,5 @@
|
|
14
17
|
"url": "git@github.com:adevinta/spark.git",
|
15
18
|
"directory": "packages/utils/tailwind-plugins"
|
16
19
|
},
|
17
|
-
"gitHead": "
|
20
|
+
"gitHead": "b02d8de6793827cb8783dcf1029c7345c7629974"
|
18
21
|
}
|
@@ -0,0 +1,15 @@
|
|
1
|
+
const tailwindCategoryKeys = {
|
2
|
+
colors: 'colors',
|
3
|
+
fontSize: 'fontSize',
|
4
|
+
screens: 'screens',
|
5
|
+
}
|
6
|
+
|
7
|
+
const unassignedColors = {
|
8
|
+
inherit: 'inherit',
|
9
|
+
current: 'currentColor',
|
10
|
+
transparent: 'transparent',
|
11
|
+
}
|
12
|
+
|
13
|
+
const DEFAULT_KEY = 'DEFAULT'
|
14
|
+
|
15
|
+
module.exports = { tailwindCategoryKeys, unassignedColors, DEFAULT_KEY }
|
@@ -0,0 +1,53 @@
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-var-requires */
|
2
|
+
const { DEFAULT_KEY } = require('./constants')
|
3
|
+
const { hexRgb } = require('./hexRgb')
|
4
|
+
|
5
|
+
const {
|
6
|
+
doubleHyphensRegex,
|
7
|
+
getRemEquivalentValue,
|
8
|
+
isHex,
|
9
|
+
isObject,
|
10
|
+
isStringOrNumber,
|
11
|
+
toKebabCase,
|
12
|
+
} = require('./utils')
|
13
|
+
|
14
|
+
function getCSSVariableDeclarations(_theme, htmlFontSize) {
|
15
|
+
const CSSVariableObj = {}
|
16
|
+
|
17
|
+
function traverse(theme, paths = []) {
|
18
|
+
Object.entries(theme).forEach(([key, value]) => {
|
19
|
+
if (isObject(value)) {
|
20
|
+
return traverse(value, paths.concat(key))
|
21
|
+
}
|
22
|
+
|
23
|
+
if (isStringOrNumber(value)) {
|
24
|
+
const getFormattedValue = () => {
|
25
|
+
if (isHex(value)) {
|
26
|
+
const { red, green, blue } = hexRgb(value)
|
27
|
+
|
28
|
+
return `${red} ${green} ${blue}`
|
29
|
+
}
|
30
|
+
|
31
|
+
if (/rem$/gi.test(value)) {
|
32
|
+
return getRemEquivalentValue(value, htmlFontSize)
|
33
|
+
}
|
34
|
+
|
35
|
+
return value
|
36
|
+
}
|
37
|
+
|
38
|
+
CSSVariableObj[
|
39
|
+
`--${[...paths, key === DEFAULT_KEY ? key.toLowerCase() : key]
|
40
|
+
.map(toKebabCase)
|
41
|
+
.join('-')
|
42
|
+
.replace(doubleHyphensRegex, '-')}`
|
43
|
+
] = getFormattedValue()
|
44
|
+
}
|
45
|
+
})
|
46
|
+
}
|
47
|
+
|
48
|
+
traverse(_theme)
|
49
|
+
|
50
|
+
return CSSVariableObj
|
51
|
+
}
|
52
|
+
|
53
|
+
module.exports = { getCSSVariableDeclarations }
|
@@ -0,0 +1,111 @@
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-var-requires */
|
2
|
+
const { DEFAULT_KEY, tailwindCategoryKeys, unassignedColors } = require('./constants')
|
3
|
+
const {
|
4
|
+
doubleHyphensRegex,
|
5
|
+
hasNumber,
|
6
|
+
isAlphanumericWithLeadingLetter,
|
7
|
+
isCamelCase,
|
8
|
+
isHex,
|
9
|
+
isObject,
|
10
|
+
isStringOrNumber,
|
11
|
+
toKebabCase,
|
12
|
+
} = require('./utils')
|
13
|
+
|
14
|
+
function getCSSVariableReferences(_theme) {
|
15
|
+
const themeCpy = JSON.parse(JSON.stringify(_theme))
|
16
|
+
|
17
|
+
const { fontSize, colors, screens } = tailwindCategoryKeys
|
18
|
+
|
19
|
+
/* eslint-disable complexity */
|
20
|
+
function traverse(theme, paths = []) {
|
21
|
+
Object.entries(theme).forEach(([key, value]) => {
|
22
|
+
// 👀 see: https://tailwindcss.com/docs/font-size#providing-a-default-line-height
|
23
|
+
if (isObject(value) && !paths.length && key === fontSize) {
|
24
|
+
Object.keys(value).forEach(k => {
|
25
|
+
const prefix = toKebabCase(fontSize)
|
26
|
+
if (isStringOrNumber(value[k])) {
|
27
|
+
theme[key][k] = `var(--${prefix}-${k})`
|
28
|
+
|
29
|
+
return
|
30
|
+
}
|
31
|
+
|
32
|
+
const kebabedKey = isCamelCase(k) || hasNumber(k) ? toKebabCase(k) : k
|
33
|
+
|
34
|
+
if (kebabedKey !== k) {
|
35
|
+
const tmp = theme[key][k]
|
36
|
+
delete theme[key][k]
|
37
|
+
theme[key][kebabedKey] = tmp
|
38
|
+
}
|
39
|
+
|
40
|
+
theme[key][kebabedKey] = [
|
41
|
+
`var(--${prefix}-${kebabedKey}-font-size)`,
|
42
|
+
{
|
43
|
+
...(value[kebabedKey].lineHeight && {
|
44
|
+
lineHeight: `var(--${prefix}-${kebabedKey}-line-height)`,
|
45
|
+
}),
|
46
|
+
...(value[kebabedKey].letterSpacing && {
|
47
|
+
letterSpacing: `var(--${prefix}-${kebabedKey}-letter-spacing)`,
|
48
|
+
}),
|
49
|
+
...(value[kebabedKey].fontWeight && {
|
50
|
+
fontWeight: `var(--${prefix}-${kebabedKey}-font-weight)`,
|
51
|
+
}),
|
52
|
+
},
|
53
|
+
]
|
54
|
+
})
|
55
|
+
|
56
|
+
return
|
57
|
+
}
|
58
|
+
|
59
|
+
if (isObject(value)) {
|
60
|
+
Object.keys(value).forEach(k => {
|
61
|
+
if (k === DEFAULT_KEY) {
|
62
|
+
return
|
63
|
+
}
|
64
|
+
|
65
|
+
if (!isObject(value[k]) && !isCamelCase(k)) {
|
66
|
+
return
|
67
|
+
}
|
68
|
+
|
69
|
+
const tmp = value[k]
|
70
|
+
delete value[k]
|
71
|
+
value[toKebabCase(k)] = tmp
|
72
|
+
})
|
73
|
+
|
74
|
+
return traverse(value, paths.concat(key))
|
75
|
+
}
|
76
|
+
|
77
|
+
if (isStringOrNumber(value)) {
|
78
|
+
const rootPath = paths[0] || ''
|
79
|
+
const isScreenValue = rootPath.includes(screens)
|
80
|
+
const isColorValue = rootPath.includes(colors)
|
81
|
+
|
82
|
+
const formattedValue = (() => {
|
83
|
+
if (isColorValue && isHex(value)) {
|
84
|
+
return `rgb(var(--${paths.join('-')}-${key}) / <alpha-value>)`
|
85
|
+
}
|
86
|
+
if (isScreenValue) {
|
87
|
+
return String(value).toLowerCase()
|
88
|
+
}
|
89
|
+
|
90
|
+
return `var(--${paths.join('-')}-${key.toLowerCase()})`
|
91
|
+
})()
|
92
|
+
|
93
|
+
const formattedKey = isAlphanumericWithLeadingLetter(key) ? toKebabCase(key) : key
|
94
|
+
|
95
|
+
if (formattedKey !== key) {
|
96
|
+
delete theme[key]
|
97
|
+
}
|
98
|
+
|
99
|
+
theme[formattedKey] = isScreenValue
|
100
|
+
? formattedValue
|
101
|
+
: toKebabCase(formattedValue).replace(doubleHyphensRegex, '-')
|
102
|
+
}
|
103
|
+
})
|
104
|
+
}
|
105
|
+
|
106
|
+
traverse(themeCpy)
|
107
|
+
|
108
|
+
return { ...themeCpy, colors: { ...themeCpy.colors, ...unassignedColors } }
|
109
|
+
}
|
110
|
+
|
111
|
+
module.exports = { getCSSVariableReferences }
|
@@ -0,0 +1,51 @@
|
|
1
|
+
// see 👀: https://github.com/sindresorhus/hex-rgb/blob/main/index.js
|
2
|
+
|
3
|
+
const hexCharacters = 'a-f\\d'
|
4
|
+
const match3or4Hex = `#?[${hexCharacters}]{3}[${hexCharacters}]?`
|
5
|
+
const match6or8Hex = `#?[${hexCharacters}]{6}([${hexCharacters}]{2})?`
|
6
|
+
const nonHexChars = new RegExp(`[^#${hexCharacters}]`, 'gi')
|
7
|
+
const validHexSize = new RegExp(`^${match3or4Hex}$|^${match6or8Hex}$`, 'i')
|
8
|
+
|
9
|
+
/* eslint-disable complexity */
|
10
|
+
function hexRgb(hex, options = {}) {
|
11
|
+
if (typeof hex !== 'string' || nonHexChars.test(hex) || !validHexSize.test(hex)) {
|
12
|
+
throw new TypeError('Expected a valid hex string')
|
13
|
+
}
|
14
|
+
|
15
|
+
hex = hex.replace(/^#/, '')
|
16
|
+
let alphaFromHex = 1
|
17
|
+
|
18
|
+
if (hex.length === 8) {
|
19
|
+
alphaFromHex = Number.parseInt(hex.slice(6, 8), 16) / 255
|
20
|
+
hex = hex.slice(0, 6)
|
21
|
+
}
|
22
|
+
|
23
|
+
if (hex.length === 4) {
|
24
|
+
alphaFromHex = Number.parseInt(hex.slice(3, 4).repeat(2), 16) / 255
|
25
|
+
hex = hex.slice(0, 3)
|
26
|
+
}
|
27
|
+
|
28
|
+
if (hex.length === 3) {
|
29
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
|
30
|
+
}
|
31
|
+
|
32
|
+
const number = Number.parseInt(hex, 16)
|
33
|
+
const red = number >> 16
|
34
|
+
const green = (number >> 8) & 255
|
35
|
+
const blue = number & 255
|
36
|
+
const alpha = typeof options.alpha === 'number' ? options.alpha : alphaFromHex
|
37
|
+
|
38
|
+
if (options.format === 'array') {
|
39
|
+
return [red, green, blue, alpha]
|
40
|
+
}
|
41
|
+
|
42
|
+
if (options.format === 'css') {
|
43
|
+
const alphaString = alpha === 1 ? '' : ` / ${Number((alpha * 100).toFixed(2))}%`
|
44
|
+
|
45
|
+
return `rgb(${red} ${green} ${blue}${alphaString})`
|
46
|
+
}
|
47
|
+
|
48
|
+
return { red, green, blue, alpha }
|
49
|
+
}
|
50
|
+
|
51
|
+
module.exports = { hexRgb }
|
@@ -0,0 +1,98 @@
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-var-requires */
|
2
|
+
const { getCSSVariableDeclarations } = require('./getCSSVariableDeclarations')
|
3
|
+
const { getCSSVariableReferences } = require('./getCSSVariableReferences')
|
4
|
+
const { retrieveArrayDifferences, getAllObjectKeys } = require('./utils')
|
5
|
+
|
6
|
+
const themeUtils = require('@spark-ui/theme-utils')
|
7
|
+
const plugin = require('tailwindcss/plugin')
|
8
|
+
|
9
|
+
const missingDefaultThemeErrorMsg =
|
10
|
+
'A default theme is required. Please ensure that the "themes" object passed to this plugin includes a "default" key containing your default theme.'
|
11
|
+
|
12
|
+
const additionalItemsErrorMsg = (themeLabel, keys) =>
|
13
|
+
`The following keys: ${JSON.stringify(
|
14
|
+
keys
|
15
|
+
)} do not adhere to our Spark Theme interface and should be removed from the ${themeLabel} theme`
|
16
|
+
|
17
|
+
const missingItemsErrorMsg = (themeLabel, keys) =>
|
18
|
+
`The following keys: ${JSON.stringify(
|
19
|
+
keys
|
20
|
+
)} are missing from the ${themeLabel} theme, but required to comply with our Spark Theme interface`
|
21
|
+
|
22
|
+
module.exports = plugin.withOptions(
|
23
|
+
/**
|
24
|
+
* @typedef {Object} Options
|
25
|
+
* @property {Object} options.themes - An object containing your themes where each key corresponds to a data-theme attribute value.
|
26
|
+
* @property {number} htmlFontSize The base font size of your app.
|
27
|
+
*/
|
28
|
+
|
29
|
+
/**
|
30
|
+
* @param {Object} options The options for the plugin.
|
31
|
+
* @param {Object} [options.themes={}] An object containing your themes where each key corresponds to a data-theme attribute value.
|
32
|
+
* @param {string} [options.htmlFontSize=16] The base font size to use to properly compute rem values.
|
33
|
+
* @returns {Function} The PostCSS plugin function.
|
34
|
+
*/
|
35
|
+
options =>
|
36
|
+
({ addBase }) => {
|
37
|
+
const opts = options || {
|
38
|
+
themes: {},
|
39
|
+
}
|
40
|
+
|
41
|
+
const { htmlFontSize = 16, themes } = opts
|
42
|
+
|
43
|
+
if (!themes.default) {
|
44
|
+
throw new Error(missingDefaultThemeErrorMsg)
|
45
|
+
}
|
46
|
+
|
47
|
+
const { missingItems, additionalItems } = retrieveArrayDifferences({
|
48
|
+
ref: getAllObjectKeys(themeUtils.defaultTheme),
|
49
|
+
comp: getAllObjectKeys(themes.default),
|
50
|
+
})
|
51
|
+
|
52
|
+
if (missingItems.length) {
|
53
|
+
throw new Error(missingItemsErrorMsg('default', missingItems))
|
54
|
+
}
|
55
|
+
if (additionalItems.length) {
|
56
|
+
throw new Error(additionalItemsErrorMsg('default', additionalItems))
|
57
|
+
}
|
58
|
+
|
59
|
+
addBase({
|
60
|
+
':root': getCSSVariableDeclarations(themes.default, htmlFontSize),
|
61
|
+
})
|
62
|
+
|
63
|
+
Object.entries(themes).forEach(([key, value]) => {
|
64
|
+
if (key === 'default') {
|
65
|
+
return
|
66
|
+
}
|
67
|
+
|
68
|
+
const { missingItems, additionalItems } = retrieveArrayDifferences({
|
69
|
+
ref: getAllObjectKeys(themeUtils.defaultTheme),
|
70
|
+
comp: getAllObjectKeys(value),
|
71
|
+
})
|
72
|
+
|
73
|
+
if (missingItems.length) {
|
74
|
+
throw new Error(missingItemsErrorMsg(key, missingItems))
|
75
|
+
}
|
76
|
+
if (additionalItems.length) {
|
77
|
+
throw new Error(additionalItemsErrorMsg(key, additionalItems))
|
78
|
+
}
|
79
|
+
|
80
|
+
addBase({
|
81
|
+
[`[data-theme="${key}"]`]: getCSSVariableDeclarations(value, htmlFontSize),
|
82
|
+
})
|
83
|
+
})
|
84
|
+
},
|
85
|
+
options => {
|
86
|
+
const opts = options || {
|
87
|
+
themes: {},
|
88
|
+
}
|
89
|
+
|
90
|
+
const { themes } = opts
|
91
|
+
|
92
|
+
if (!themes.default) {
|
93
|
+
throw new Error(missingDefaultThemeErrorMsg)
|
94
|
+
}
|
95
|
+
|
96
|
+
return { theme: getCSSVariableReferences(themes.default) }
|
97
|
+
}
|
98
|
+
)
|
@@ -0,0 +1,83 @@
|
|
1
|
+
function isObject(x) {
|
2
|
+
return !!x && x.constructor === Object
|
3
|
+
}
|
4
|
+
|
5
|
+
function isStringOrNumber(value) {
|
6
|
+
return typeof value === 'string' || typeof value === 'number'
|
7
|
+
}
|
8
|
+
|
9
|
+
function isHex(value) {
|
10
|
+
if (typeof value === 'number') {
|
11
|
+
return false
|
12
|
+
}
|
13
|
+
|
14
|
+
const regexp = /^#[0-9a-fA-F]+$/
|
15
|
+
|
16
|
+
return regexp.test(value)
|
17
|
+
}
|
18
|
+
|
19
|
+
function isAlphanumericWithLeadingLetter(v) {
|
20
|
+
return /^[a-zA-Z](?=.*\d)[a-zA-Z\d]*$/.test(v)
|
21
|
+
}
|
22
|
+
|
23
|
+
function isCamelCase(value) {
|
24
|
+
return /[A-Z]/.test(value.slice(1))
|
25
|
+
}
|
26
|
+
|
27
|
+
function hasNumber(value) {
|
28
|
+
return /\d/.test(value)
|
29
|
+
}
|
30
|
+
|
31
|
+
function getRemEquivalentValue(remValue, htmlFontSize) {
|
32
|
+
const defaultBrowserBase = 16
|
33
|
+
const pxValue = parseFloat(remValue) * defaultBrowserBase
|
34
|
+
|
35
|
+
return `${pxValue / htmlFontSize}rem`
|
36
|
+
}
|
37
|
+
|
38
|
+
function toKebabCase(v) {
|
39
|
+
return v.replace(/[A-Z]+(?=[a-z0-9])|\d+/g, match => '-' + match.toLowerCase())
|
40
|
+
}
|
41
|
+
|
42
|
+
const doubleHyphensRegex = /(?<!var\()--+/g
|
43
|
+
|
44
|
+
function getAllObjectKeys(obj, path = '') {
|
45
|
+
return Object.keys(obj).flatMap(key => {
|
46
|
+
const newPath = path ? `${path}.${key}` : key
|
47
|
+
if (isObject(obj[key])) {
|
48
|
+
return getAllObjectKeys(obj[key], newPath)
|
49
|
+
}
|
50
|
+
|
51
|
+
return newPath
|
52
|
+
})
|
53
|
+
}
|
54
|
+
|
55
|
+
function difference(as, bs) {
|
56
|
+
const set = new Set(bs)
|
57
|
+
|
58
|
+
return as.filter(a => !set.has(a))
|
59
|
+
}
|
60
|
+
|
61
|
+
function retrieveArrayDifferences({ ref, comp }) {
|
62
|
+
const additionalItems = difference(comp, ref)
|
63
|
+
const missingItems = difference(ref, comp)
|
64
|
+
|
65
|
+
return {
|
66
|
+
additionalItems,
|
67
|
+
missingItems,
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
module.exports = {
|
72
|
+
isObject,
|
73
|
+
isStringOrNumber,
|
74
|
+
isHex,
|
75
|
+
isAlphanumericWithLeadingLetter,
|
76
|
+
isCamelCase,
|
77
|
+
hasNumber,
|
78
|
+
getRemEquivalentValue,
|
79
|
+
toKebabCase,
|
80
|
+
doubleHyphensRegex,
|
81
|
+
getAllObjectKeys,
|
82
|
+
retrieveArrayDifferences,
|
83
|
+
}
|