Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Jordan Pittman
407443c1a6 Add support for theme sub-variables by returning tuples 2024-07-29 16:03:00 -04:00
Jordan Pittman
b9f618b11c Upgrade theme calls so merged values from v3 are included
This is done purely for backwards compatability and is intentionally NOT done when requesting a theme value using the CSS property syntax. e.g. `theme(“--accent-color”)`
2024-07-29 14:00:56 -04:00
Jordan Pittman
c763a7799c Add support for the theme function to plugins 2024-07-29 14:00:56 -04:00
4 changed files with 334 additions and 3 deletions

View file

@ -14,6 +14,7 @@ import {
import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { buildPluginApi } from './plugin-api'
import { Theme } from './theme'
import { segment } from './utils/segment'
@ -21,6 +22,7 @@ const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
type PluginAPI = {
addVariant(name: string, variant: string | string[] | CssInJs): void
theme(path: string, fallback?: any): any
}
type Plugin = (api: PluginAPI) => void
@ -280,6 +282,8 @@ export function compile(
designSystem.variants.fromAst(name, objectToAst(variant))
}
},
...buildPluginApi(designSystem),
}
for (let plugin of plugins) {

View file

@ -0,0 +1,164 @@
import type { DesignSystem } from './design-system'
import { DefaultMap } from './utils/default-map'
export function buildPluginApi(designSystem: DesignSystem) {
return {
theme(path: string, fallback?: any) {
if (path.startsWith('--')) {
if (path.endsWith('-*')) {
return Object.fromEntries(
designSystem.theme.namespace(path.slice(0, -2) as any).entries(),
)
}
return designSystem.theme.resolveValue(null, [path] as any) ?? fallback ?? null
}
let original = path
// Escape dots used inside square brackets
.replace(/\[(.*?)\]/g, (_, value) => `-${value.replace('.', '_')}`)
// Replace dots with dashes
.replace(/\./g, '-')
// Replace camelCase with dashes
.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`)
// Perform an "upgrade" on the path so that, for example, a request for
// accentColor merges values from --color-* and --accent-color-*
let paths: string[] = []
for (let prefix in themeUpgradeMap) {
if (!original.startsWith(prefix)) continue
// This makes sure that:
// `accent-color` is turned into `color`; AND
// `accent-color-foo` is turned into `color-foo`
let suffix = original.slice(prefix.length)
for (let upgrade of themeUpgradeMap[prefix]) {
paths.push(upgrade + suffix)
}
}
// Make sure the original path is included last because it should take precedence
paths.push(original)
type ThemeValue =
// A normal string value
| string
// A nested tuple with additional data
| [main: string, extra: Record<string, string>]
let map = new Map<string | null, ThemeValue>()
let nested = new DefaultMap<string | null, Map<string, string>>(() => new Map())
for (let path of paths) {
let ns = designSystem.theme.namespace(`--${path}` as any)
for (let [key, value] of ns) {
// Non-nested values can be set directly
if (!key || !key.includes('--')) {
map.set(key, value)
continue
}
// Nested values are stored separately
let nestedIndex = key.indexOf('--')
let mainKey = key.slice(0, nestedIndex)
let nestedKey = key.slice(nestedIndex + 2)
// Make `nestedKey` camel case:
nestedKey = nestedKey.replace(/-([a-z])/g, (_, a) => a.toUpperCase())
nested.get(mainKey === '' ? null : mainKey).set(nestedKey, value)
}
}
for (let [key, extra] of nested) {
let value = map.get(key)
if (typeof value !== 'string') continue
map.set(key, [value, Object.fromEntries(extra)])
}
// Now we've got the "upgraded" list of theme values let's look for the requested value
// Does the requested value exist in the theme
if (map.has(null)) {
// Yes, and there are multiple values in the requested theme namespace
if (map.size > 1) {
return {
DEFAULT: map.get(null),
...Object.fromEntries(Array.from(map.entries()).filter(([key]) => key !== null)),
}
}
// Nope, just the one
return map.get(null)
}
// There is at least one value in the requested theme namespace
// but no default value
if (map.size > 0) {
return Object.fromEntries(map)
}
return fallback ?? null
},
}
}
let themeUpgradeMap: Record<string, string[]> = {
'accent-color': ['color'],
'backdrop-blur': ['blur'],
'backdrop-brightness': ['brightness'],
'backdrop-contrast': ['contrast'],
'backdrop-grayscale': ['grayscale'],
'backdrop-hue-rotate': ['hue-rotate'],
'backdrop-invert': ['invert'],
'backdrop-opacity': ['opacity'],
'backdrop-saturate': ['saturate'],
'backdrop-sepia': ['sepia'],
'background-color': ['color'],
'background-opacity': ['opacity'],
'border-color': ['color'],
'border-opacity': ['opacity'],
'border-spacing': ['spacing'],
'box-shadow-color': ['color'],
'caret-color': ['color'],
colors: ['color'],
'divide-color': ['border-color', 'color'],
'divide-opacity': ['border-opacity', 'opacity'],
'divide-width': ['border-width'],
fill: ['color'],
'flex-basis': ['spacing'],
gap: ['spacing'],
'gradient-color-stops': ['color'],
height: ['spacing'],
inset: ['spacing'],
margin: ['spacing'],
'max-height': ['spacing'],
'max-width': ['spacing', 'breakpoint'],
'min-height': ['spacing'],
'min-width': ['spacing'],
'outline-color': ['color'],
padding: ['spacing'],
'placeholder-color': ['color'],
'placeholder-opacity': ['opacity'],
'ring-color': ['color'],
'ring-offset-color': ['color'],
'ring-opacity': ['opacity'],
screens: ['breakpoint'],
'scroll-margin': ['spacing'],
'scroll-padding': ['spacing'],
space: ['spacing'],
stroke: ['color'],
'text-color': ['color'],
'text-decoration-color': ['color'],
'text-indent': ['spacing'],
'text-opacity': ['opacity'],
translate: ['spacing'],
size: ['spacing'],
width: ['spacing'],
}

View file

@ -124,13 +124,16 @@ export class Theme {
namespace(namespace: string) {
let values = new Map<string | null, string>()
let prefix = `${namespace}-`
for (let [key, value] of this.values) {
if (key === namespace) {
values.set(null, value.value)
} else if (key.startsWith(prefix)) {
values.set(key.slice(prefix.length), value.value)
} else if (key.startsWith(`${namespace}--`)) {
// Preserve `--` prefix for sub-variables
// e.g. `--font-size-sm--line-height`
values.set(key.slice(namespace.length), value.value)
} else if (key.startsWith(`${namespace}-`)) {
values.set(key.slice(namespace.length + 1), value.value)
}
}

View file

@ -15150,3 +15150,163 @@ describe('custom utilities', () => {
).toThrowError(/should be alphanumeric/)
})
})
describe('theme function in plugins', () => {
let input = css`
@plugin "my-plugin";
@theme reference {
--size-2_5: 2.5rem;
--scrollbar-big: 20px;
--scrollbar-big-properties: auto-hidden;
--scrollbar-color-light: white;
--scrollbar-color-dark: black;
}
`
test('CSS property notation', () => {
expect.hasAssertions()
compile(input, {
loadPlugin() {
return ({ theme }) => {
expect(theme('--scrollbar')).toEqual(null)
expect(theme('--scrollbar-*')).toEqual({
big: '20px',
'big-properties': 'auto-hidden',
'color-dark': 'black',
'color-light': 'white',
})
expect(theme('--scrollbar-big')).toEqual('20px')
}
},
})
})
test('legacy dot notation', () => {
expect.hasAssertions()
compile(input, {
loadPlugin() {
return ({ theme }) => {
// Accessing via legacy dot notation
expect(theme('size.2_5')).toEqual('2.5rem')
expect(theme('scrollbar')).toEqual({
big: '20px',
'big-properties': 'auto-hidden',
'color-dark': 'black',
'color-light': 'white',
})
expect(theme('scrollbar.big')).toEqual({
DEFAULT: '20px',
properties: 'auto-hidden',
})
expect(theme('scrollbar.big.properties')).toEqual('auto-hidden')
expect(theme('scrollbar.color')).toEqual({
light: 'white',
dark: 'black',
})
expect(theme('scrollbar.foo', 'nope')).toEqual('nope')
expect(theme('somekey', 'nope')).toEqual('nope')
}
},
})
})
test('legacy square bracket notation', () => {
expect.hasAssertions()
compile(input, {
loadPlugin() {
return ({ theme }) => {
expect(theme('size[2.5]')).toEqual('2.5rem')
expect(theme('scrollbar[big]')).toEqual({
DEFAULT: '20px',
properties: 'auto-hidden',
})
expect(theme('scrollbar[big][properties]')).toEqual('auto-hidden')
}
},
})
})
test('path upgrades', () => {
expect.hasAssertions()
let input = css`
@plugin "my-plugin";
@theme reference {
--color-100: red;
--color-200: green;
--color-300: blue;
--accent-color-100: cyan;
}
`
compile(input, {
loadPlugin() {
return ({ theme }) => {
expect(theme('accentColor')).toEqual({
// Values from --color-* and --accent-color-* are merged into one object
// with the values from --accent-color-* taking precedence
100: 'cyan',
200: 'green',
300: 'blue',
})
// This one is defined on --accent-color-* so it is found directly
expect(theme('accentColor.100')).toEqual('cyan')
// This one is defined in --color-* so the value is taken from there
expect(theme('accentColor.200')).toEqual('green')
// The CSS property syntax is NOT upgraded
expect(theme('--accent-color-*')).toEqual({
100: 'cyan',
})
expect(theme('--accent-color-100')).toEqual('cyan')
expect(theme('--accent-color-200')).toEqual(null)
}
},
})
})
test('tuple upgrades', () => {
expect.hasAssertions()
let input = css`
@plugin "my-plugin";
@theme reference {
--font-size-sm: 0.875rem;
--font-size-sm--line-height: 1.25;
--font-size-sm--font-weight: bold;
--font-size-md: 1rem;
}
`
compile(input, {
loadPlugin() {
return ({ theme }) => {
expect(theme('fontSize')).toEqual({
sm: ['0.875rem', { lineHeight: '1.25', fontWeight: 'bold' }],
md: '1rem',
})
expect(theme('fontSize.sm')).toEqual([
'0.875rem',
{ lineHeight: '1.25', fontWeight: 'bold' },
])
expect(theme('fontSize.md')).toEqual('1rem')
}
},
})
})
})