From c763a7799cabcfd38f9b2aad6d57814a73f32c60 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Jul 2024 16:53:33 -0400 Subject: [PATCH 1/3] Add support for the theme function to plugins --- packages/tailwindcss/src/index.ts | 4 + packages/tailwindcss/src/plugin-api.ts | 53 +++++++++++++ packages/tailwindcss/src/utilities.test.ts | 86 ++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 packages/tailwindcss/src/plugin-api.ts 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..8260f0795 --- /dev/null +++ b/packages/tailwindcss/src/plugin-api.ts @@ -0,0 +1,53 @@ +import type { DesignSystem } from './design-system' + +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()}`) + + // Prepend with `--` to match CSS variables + original = `--${original}` + + let map = designSystem.theme.namespace(original as any) + + // 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.entries()) + } + + return fallback ?? null + }, + } +} + diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 4135b1cdd..af3a2a72b 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15150,3 +15150,89 @@ 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') + } + }, + }) + }) +}) From b9f618b11ca955caed9b14d29ec9ea6bfc666843 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 29 Jul 2024 11:47:15 -0400 Subject: [PATCH 2/3] Upgrade theme calls so merged values from v3 are included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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”)` --- packages/tailwindcss/src/plugin-api.ts | 86 +++++++++++++++++++++- packages/tailwindcss/src/utilities.test.ts | 44 ++++++++++- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 8260f0795..69f4390db 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -21,10 +21,37 @@ export function buildPluginApi(designSystem: DesignSystem) { // Replace camelCase with dashes .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) - // Prepend with `--` to match CSS variables - original = `--${original}` + // Perform an "upgrade" on the path so that, for example, a request for + // accentColor merges values from --color-* and --accent-color-* + let paths: string[] = [] - let map = designSystem.theme.namespace(original as any) + 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) + + let map = new Map() + + for (let path of paths) { + let ns = designSystem.theme.namespace(`--${path}` as any) + + for (let [key, value] of ns) { + map.set(key, value) + } + } + + // 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)) { @@ -51,3 +78,56 @@ export function buildPluginApi(designSystem: DesignSystem) { } } +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/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index af3a2a72b..e47e825dd 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15162,7 +15162,7 @@ describe('theme function in plugins', () => { --scrollbar-color-light: white; --scrollbar-color-dark: black; - }, + } ` test('CSS property notation', () => { @@ -15235,4 +15235,46 @@ describe('theme function in plugins', () => { }, }) }) + + 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) + } + }, + }) + }) }) From 407443c1a611270e7f7946290ac1dafd43684151 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 29 Jul 2024 16:01:57 -0400 Subject: [PATCH 3/3] Add support for theme sub-variables by returning tuples --- packages/tailwindcss/src/plugin-api.ts | 37 ++++++++++++++++++++-- packages/tailwindcss/src/theme.ts | 9 ++++-- packages/tailwindcss/src/utilities.test.ts | 32 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 69f4390db..6fa7a6a84 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,4 +1,5 @@ import type { DesignSystem } from './design-system' +import { DefaultMap } from './utils/default-map' export function buildPluginApi(designSystem: DesignSystem) { return { @@ -41,16 +42,46 @@ export function buildPluginApi(designSystem: DesignSystem) { // Make sure the original path is included last because it should take precedence paths.push(original) - let map = new Map() + 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) { - map.set(key, value) + // 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 @@ -70,7 +101,7 @@ export function buildPluginApi(designSystem: DesignSystem) { // There is at least one value in the requested theme namespace // but no default value if (map.size > 0) { - return Object.fromEntries(map.entries()) + return Object.fromEntries(map) } return fallback ?? null 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 e47e825dd..7100850db 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15277,4 +15277,36 @@ describe('theme function in plugins', () => { }, }) }) + + 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') + } + }, + }) + }) })