Compare commits
3 commits
main
...
feat/v4-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407443c1a6 | ||
|
|
b9f618b11c | ||
|
|
c763a7799c |
4 changed files with 334 additions and 3 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
164
packages/tailwindcss/src/plugin-api.ts
Normal file
164
packages/tailwindcss/src/plugin-api.ts
Normal 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'],
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue