This commit is contained in:
Jordan Pittman 2024-08-14 16:39:38 -04:00
parent d6ccd80017
commit 1d2f9ba687
2 changed files with 292 additions and 10 deletions

View file

@ -1,4 +1,4 @@
import { describe, test } from 'vitest'
import { describe, test, vi } from 'vitest'
import { compile } from '.'
import plugin from './plugin'
@ -289,17 +289,13 @@ describe('theme', async () => {
})
expect(compiler.build(['animate-duration'])).toMatchInlineSnapshot(`
".animate-delay {
".animate-duration {
animation-delay: 1500ms;
}
"
`)
})
// 1. Access "new" theme keys from CSS using the old theme key notation
// e.g. read `--animation-*` using theme('animation')
// e.g. read `--animation-foo` using theme('animation.foo')
test('plugins can read CSS theme keys using the old theme key notation', async ({ expect }) => {
let input = css`
@tailwind utilities;
@ -337,8 +333,153 @@ describe('theme', async () => {
},
})
expect(
compiler.build(['animation-spin', 'animation', 'animation2', 'animation2-twist']),
).toMatchInlineSnapshot(`""`)
expect(compiler.build(['animation-spin', 'animation', 'animation2', 'animation2-twist']))
.toMatchInlineSnapshot(`
".animation {
--animation: pulse 1s linear infinite;
}
.animation-spin {
--animation: spin 1s linear infinite;
}
.animation2 {
--animation: pulse 1s linear infinite;
}
.animation2-twist {
--animation: spin 1s linear infinite;
}
"
`)
})
test('CSS theme values are mreged with JS theme values', async ({ expect }) => {
let input = css`
@tailwind utilities;
@plugin "my-plugin";
@theme reference {
--animation: pulse 1s linear infinite;
--animation-spin: spin 1s linear infinite;
}
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin(
function ({ matchUtilities, theme }) {
matchUtilities(
{
animation: (value) => ({ '--animation': value }),
},
{
values: theme('animation'),
},
)
},
{
theme: {
extend: {
animation: {
bounce: 'bounce 1s linear infinite',
},
},
},
},
)
},
})
expect(compiler.build(['animation', 'animation-spin', 'animation-bounce']))
.toMatchInlineSnapshot(`
".animation {
--animation: pulse 1s linear infinite;
}
.animation-bounce {
--animation: bounce 1s linear infinite;
}
.animation-spin {
--animation: spin 1s linear infinite;
}
"
`)
})
test('CSS theme defaults take precedence over JS theme defaults', async ({ expect }) => {
let input = css`
@tailwind utilities;
@plugin "my-plugin";
@theme reference {
--animation: pulse 1s linear infinite;
--animation-spin: spin 1s linear infinite;
}
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin(
function ({ matchUtilities, theme }) {
matchUtilities(
{
animation: (value) => ({ '--animation': value }),
},
{
values: theme('animation'),
},
)
},
{
theme: {
extend: {
animation: {
DEFAULT: 'twist 1s linear infinite',
},
},
},
},
)
},
})
expect(compiler.build(['animation'])).toMatchInlineSnapshot(`
".animation {
--animation: pulse 1s linear infinite;
}
"
`)
})
test('CSS theme values take precedence even over non-object JS values', async ({ expect }) => {
let input = css`
@tailwind utilities;
@plugin "my-plugin";
@theme reference {
--animation-simple-spin: spin 1s linear infinite;
--animation-simple-bounce: bounce 1s linear infinite;
}
`
let fn = vi.fn()
await compile(input, {
loadPlugin: async () => {
return plugin(
function ({ theme }) {
fn(theme('animation.simple'))
},
{
theme: {
extend: {
animation: {
simple: 'simple 1s linear',
},
},
},
},
)
},
})
expect(fn).toHaveBeenCalledWith({
spin: 'spin 1s linear infinite',
bounce: 'bounce 1s linear infinite',
})
})
})

View file

@ -1,10 +1,12 @@
import { substituteAtApply } from './apply'
import { objectToAst, rule, type AstNode, type CssInJs } from './ast'
import { deepMerge } from './compat/config/deep-merge'
import { resolveConfig } from './compat/config/resolve-config'
import type { UserConfig } from './compat/config/types'
import type { DesignSystem } from './design-system'
import type { Theme } from './theme'
import { withAlpha, withNegative } from './utilities'
import { DefaultMap } from './utils/default-map'
import { inferDataType } from './utils/infer-data-type'
import { segment } from './utils/segment'
@ -208,6 +210,13 @@ export function buildPluginApi(
}
},
theme(path) {
let readFromCss = createReadFromCss(designSystem.theme)
let cssThemeValue = readFromCss(path)
if (typeof cssThemeValue !== 'object') {
return cssThemeValue
}
if (path === 'colors') {
let result: Record<string, string> = resolvedConfig.theme?.colors ?? {}
@ -218,9 +227,25 @@ export function buildPluginApi(
return result
}
let parts = segment(path, '.')
let result = resolvedConfig.theme ?? {}
// 1. Convert `path` to an array of parts (segment)
// 2. Find the "obeject" that corresponds to the path
// 3. Deep merge the object with the resolved theme
let parts = segment(path, '.')
let obj = get(result, parts) ?? {}
if (typeof obj === 'object') {
deepMerge(obj, [cssThemeValue], (a, b) => b)
set(result, parts, obj)
} else {
// This case happens when the CSS theme produces an object for a given
// keypath but the v3-style config does not. However we still want the
// CSS to "win" in this case so we overwrite the v3-style config value.
set(result, parts, cssThemeValue)
}
for (let part of parts) {
result = result?.[part]
}
@ -288,3 +313,119 @@ export function createCompatabilityConfig(theme: Theme) {
},
}
}
function createReadFromCss(theme: Theme) {
type ThemeValue =
// A normal string value
| string
// A nested tuple with additional data
| [main: string, extra: Record<string, string>]
return function readFromCss(path: string) {
// This only supports reading from the CSS Theme:…
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[] = []
// Make sure the original path is included last because it should take precedence
paths.push(original)
let map = new Map<string | null, ThemeValue>()
let nested = new DefaultMap<string | null, Map<string, string>>(() => new Map())
for (let path of paths) {
path = path.replace('-DEFAULT', '')
let ns = 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)])
}
// We have to turn the map into object-like structure for v3 compatibility
let obj = {}
let useNestedObjects = false // paths.some((path) => nestedKeys.has(path))
for (let [key, value] of map) {
key = key ?? 'DEFAULT'
let path: string[] = []
let splitIndex = key.indexOf('-')
if (useNestedObjects && splitIndex !== -1) {
path.push(key.slice(0, splitIndex))
path.push(key.slice(splitIndex + 1))
} else {
path.push(key)
}
set(obj, path, value)
}
if (path.endsWith('.DEFAULT') && 'DEFAULT' in obj) {
return obj.DEFAULT ?? null
}
if (Object.keys(obj).length === 1 && 'DEFAULT' in obj) {
return obj.DEFAULT
}
return obj
}
}
function get(obj: any, path: string[]) {
for (let key of path) {
if (obj[key] === undefined) {
return undefined
}
obj = obj[key]
}
return obj
}
function set(obj: any, path: string[], value: any) {
for (let key of path.slice(0, -1)) {
if (obj[key] === undefined) {
obj[key] = {}
}
obj = obj[key]
}
obj[path[path.length - 1]] = value
}