Work on theme value merging

This commit is contained in:
Jordan Pittman 2024-08-22 14:14:41 -04:00 • committed by Philipp Spiess
parent f2b7fbae6b
commit bb89c7c6df
4 changed files with 214 additions and 2 deletions

View file

@ -195,3 +195,37 @@ test('Config files can affect the theme', async ({ expect }) => {
"
`)
})
test('Variants in CSS overwrite variants from plugins', async ({ expect }) => {
let input = css`
@tailwind utilities;
@config "./config.js";
@variant dark (&:is(.my-dark));
@variant light (&:is(.my-light));
`
let compiler = await compile(input, {
loadConfig: async () => ({
darkMode: ['variant', '&:is(.dark)'],
plugins: [
plugin(function ({ addVariant }) {
addVariant('light', '&:is(.light)')
}),
],
}),
})
expect(compiler.build(['dark:underline', 'light:underline'])).toMatchInlineSnapshot(`
".dark\\:underline {
&:is(.my-dark) {
text-decoration-line: underline;
}
}
.light\\:underline {
&:is(.my-light) {
text-decoration-line: underline;
}
}
"
`)
})

View file

@ -0,0 +1,47 @@
import { test } from 'vitest'
import { Theme } from '../theme'
import { mergeIntoTheme } from './merge-into-theme'
test('Config values can be merged into the theme', ({ expect }) => {
let theme = new Theme()
mergeIntoTheme(theme, {
colors: {
primary: '#c0ffee',
red: {
500: 'red',
},
},
fontSize: {
sm: '0.875rem',
base: [
'1rem',
{
lineHeight: '1.5',
},
],
},
})
expect(theme.resolve('primary', ['--colors'] as any[])).toEqual('#c0ffee')
expect(theme.resolve('red-500', ['--colors'] as any[])).toEqual('red')
expect(theme.resolve('sm', ['--font-size'] as any[])).toEqual('0.875rem')
expect(theme.resolve('base', ['--font-size'] as any[])).toEqual('1rem')
expect(theme.get(['--font-size-base--line-height'] as any[])).toEqual('1.5')
})
test('Existing values are not overwritten', ({ expect }) => {
let theme = new Theme()
theme.add('--colors-primary', '#c0ffee')
mergeIntoTheme(theme, {
colors: {
primary: '#c0ffee',
secondary: '#bada55',
},
})
expect(theme.resolve('primary', ['--colors'] as any[])).toEqual('var(--colors-primary, #c0ffee)')
expect(theme.resolve('secondary', ['--colors'] as any[])).toEqual('#bada55')
})

View file

@ -0,0 +1,125 @@
import type { DesignSystem } from '../design-system'
import { resolveConfig, type ConfigFile } from './config/resolve-config'
import type { ResolvedConfig } from './config/types'
export function mergeIntoTheme(designSystem: DesignSystem, configs: ConfigFile[]) {
let theme = resolveConfig(designSystem, configs).theme
for (let [path, value] of themeableValues(theme)) {
let name = keyPathToCssProperty(path)
designSystem.theme.add(`--${name}`, value as any, {
isInline: true,
isReference: false,
})
}
return theme
}
function themeableValues(config: ResolvedConfig['theme']): [string[], unknown][] {
let toAdd: [string[], unknown][] = []
walk(config as any, [], (value, path) => {
if (isValidThemePrimitive(value)) {
toAdd.push([path, value])
return WalkAction.Skip
}
if (isValidThemeTuple(value)) {
toAdd.push([path, value[0]])
for (let key of Reflect.ownKeys(value[1]) as string[]) {
toAdd.push([[...path, `-${key}`], value[1][key]])
}
return WalkAction.Skip
}
})
return toAdd
}
function keyPathToCssProperty(path: string[]) {
return (
path
// [1] should move into the nested object tuple. To create the CSS variable
// name for this, we replace it with an empty string that will result in two
// subsequent dashes when joined.
.map((path) => (path === '1' ? '' : path))
// Resolve the key path to a CSS variable segment
.map((part) =>
part
.replaceAll('.', '_')
.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`),
)
// Remove the `DEFAULT` key at the end of a path
// We're reading from CSS anyway so it'll be a string
.filter((part, index) => part !== 'DEFAULT' || index !== path.length - 1)
.join('-')
)
}
function isValidThemePrimitive(value: unknown) {
return typeof value === 'number' || typeof value === 'string'
}
function isValidThemeTuple(value: unknown): value is [string, Record<string, string | number>] {
// Check for tuple values of the form
// `[string, Record<string, string | number>]`
if (!Array.isArray(value)) return false
if (value.length !== 2) return false
// A string or number as the "value"
if (typeof value[0] !== 'string' && typeof value[0] !== 'number') return false
// An object as the nested theme values
if (value[1] === undefined || value[1] === null) return false
if (typeof value[1] !== 'object') return false
for (let key of Reflect.ownKeys(value[1])) {
if (typeof key !== 'string') return false
if (typeof value[1][key] !== 'string' && typeof value[1][key] !== 'number') return false
}
return true
}
enum WalkAction {
/** Continue walking, which is the default */
Continue,
/** Skip visiting the children of this node */
Skip,
/** Stop the walk entirely */
Stop,
}
function walk(
obj: Record<string, unknown>,
path: string[] = [],
callback: (value: unknown, path: string[]) => WalkAction | void,
) {
for (let key of Reflect.ownKeys(obj) as string[]) {
let value = obj[key]
if (value === undefined || value === null) {
continue
}
let keyPath = [...path, key]
let result = callback(value, keyPath) ?? WalkAction.Continue
if (result === WalkAction.Skip) continue
if (result === WalkAction.Stop) break
if (!Array.isArray(value) && typeof value !== 'object') continue
walk(value as any, keyPath, callback)
}
}

View file

@ -5,6 +5,7 @@ import { createCompatConfig } from './compat/config/create-compat-config'
import { resolveConfig, type ConfigFile } from './compat/config/resolve-config'
import type { ResolvedConfig, UserConfig } from './compat/config/types'
import { darkModePlugin } from './compat/dark-mode'
import { mergeIntoTheme } from './compat/merge-into-theme'
import type { DesignSystem } from './design-system'
import { createThemeFn } from './theme-fn'
import { withAlpha, withNegative } from './utilities'
@ -351,10 +352,11 @@ export function registerPlugins(
ast: AstNode[],
configs: ConfigFile[],
) {
let userConfig = [{ config: { plugins } }, ...configs]
let resolvedConfig = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme) },
...configs,
{ config: { plugins } },
...userConfig,
{ config: { plugins: [darkModePlugin] } },
])
@ -364,6 +366,10 @@ export function registerPlugins(
handler(pluginApi)
}
// Theme keys don't need to read from the compat config because core plugins
// _already_ look through "fallback" theme keys
mergeIntoTheme(designSystem, userConfig)
return {
pluginApi,
resolvedConfig,