diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index f324dfad8..d9726f657 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -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) { diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts new file mode 100644 index 000000000..6fa7a6a84 --- /dev/null +++ b/packages/tailwindcss/src/plugin-api.ts @@ -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] + + let map = new Map() + let nested = new DefaultMap>(() => 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 = { + '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'], +} diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 8ac6a90cc..c606089fb 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -124,13 +124,16 @@ export class Theme { namespace(namespace: string) { let values = new Map() - 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) } } diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 4135b1cdd..7100850db 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -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') + } + }, + }) + }) +})