This commit is contained in:
Jordan Pittman 2024-08-14 11:54:04 -04:00
parent 22f72362bf
commit 5111f44efa
10 changed files with 3510 additions and 31 deletions

View file

@ -0,0 +1,293 @@
export const colors = {
inherit: 'inherit',
current: 'currentColor',
transparent: 'transparent',
black: '#000',
white: '#fff',
slate: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
zinc: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
900: '#18181b',
950: '#09090b',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0a0a0a',
},
stone: {
50: '#fafaf9',
100: '#f5f5f4',
200: '#e7e5e4',
300: '#d6d3d1',
400: '#a8a29e',
500: '#78716c',
600: '#57534e',
700: '#44403c',
800: '#292524',
900: '#1c1917',
950: '#0c0a09',
},
red: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
orange: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
950: '#431407',
},
amber: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#451a03',
},
yellow: {
50: '#fefce8',
100: '#fef9c3',
200: '#fef08a',
300: '#fde047',
400: '#facc15',
500: '#eab308',
600: '#ca8a04',
700: '#a16207',
800: '#854d0e',
900: '#713f12',
950: '#422006',
},
lime: {
50: '#f7fee7',
100: '#ecfccb',
200: '#d9f99d',
300: '#bef264',
400: '#a3e635',
500: '#84cc16',
600: '#65a30d',
700: '#4d7c0f',
800: '#3f6212',
900: '#365314',
950: '#1a2e05',
},
green: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
emerald: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
950: '#022c22',
},
teal: {
50: '#f0fdfa',
100: '#ccfbf1',
200: '#99f6e4',
300: '#5eead4',
400: '#2dd4bf',
500: '#14b8a6',
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
950: '#042f2e',
},
cyan: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#06b6d4',
600: '#0891b2',
700: '#0e7490',
800: '#155e75',
900: '#164e63',
950: '#083344',
},
sky: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
blue: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
indigo: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b',
},
violet: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
950: '#2e1065',
},
purple: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
950: '#3b0764',
},
fuchsia: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
950: '#4a044e',
},
pink: {
50: '#fdf2f8',
100: '#fce7f3',
200: '#fbcfe8',
300: '#f9a8d4',
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
700: '#be185d',
800: '#9d174d',
900: '#831843',
950: '#500724',
},
rose: {
50: '#fff1f2',
100: '#ffe4e6',
200: '#fecdd3',
300: '#fda4af',
400: '#fb7185',
500: '#f43f5e',
600: '#e11d48',
700: '#be123c',
800: '#9f1239',
900: '#881337',
950: '#4c0519',
},
}

View file

@ -0,0 +1,14 @@
export function deepFreeze<T extends object>(obj: T): T {
for (let name of Reflect.ownKeys(obj)) {
let value = obj[name as keyof T]
if (
value !== null &&
(typeof value === 'object' || typeof value === 'function') &&
!Object.isFrozen(value)
) {
deepFreeze(value)
}
}
return Object.freeze(obj)
}

View file

@ -0,0 +1,46 @@
type Customizer = (a: any, b: any) => any
export function isPlainObject<T>(value: T): value is T & Record<keyof T, unknown> {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return false
}
const prototype = Object.getPrototypeOf(value)
return prototype === null || Object.getPrototypeOf(prototype) === null
}
export function deepMerge<T extends object>(
target: T,
sources: (Partial<T> | null | undefined)[],
customizer: Customizer,
) {
type Key = keyof T
type Value = T[Key]
type KnownSource = Record<Key, Value>
for (let source of sources) {
if (source === null || source === undefined) {
continue
}
let keys = [
//
...Object.getOwnPropertyNames(source),
...Object.getOwnPropertySymbols(source),
]
for (let k of keys as Key[]) {
let merged = customizer(target[k], source[k])
if (merged !== undefined) {
target[k] = merged
} else if (!isPlainObject(target[k]) || !isPlainObject(source[k])) {
target[k] = source[k] as any
} else {
target[k] = deepMerge({}, [target[k], source[k]] as any, customizer) as any
}
}
}
return target
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
import { test } from 'vitest'
import { resolveConfig } from './resolve-config'
test('top level theme keys are replaced', ({ expect }) => {
let config = resolveConfig([
{
theme: {
colors: {
red: 'red',
},
fontFamily: {
sans: 'SF Pro Display',
},
},
},
{
theme: {
colors: {
green: 'green',
},
},
},
{
theme: {
colors: {
blue: 'blue',
},
},
},
])
expect(config).toMatchObject({
theme: {
colors: {
blue: 'blue',
},
fontFamily: {
sans: 'SF Pro Display',
},
},
})
})
test('theme can be extended', ({ expect }) => {
let config = resolveConfig([
{
theme: {
colors: {
red: 'red',
},
fontFamily: {
sans: 'SF Pro Display',
},
},
},
{
theme: {
extend: {
colors: {
blue: 'blue',
},
},
},
},
])
expect(config).toMatchObject({
theme: {
colors: {
red: 'red',
blue: 'blue',
},
fontFamily: {
sans: 'SF Pro Display',
},
},
})
})
test('theme keys can reference other theme keys using the theme function regardless of order', ({
expect,
}) => {
let config = resolveConfig([
{
theme: {
colors: {
red: 'red',
},
placeholderColor: {
green: 'green',
},
},
},
{
theme: {
extend: {
colors: ({ theme }) => ({
...theme('placeholderColor'),
blue: 'blue',
}),
},
},
},
{
theme: {
extend: {
caretColor: ({ theme }) => theme('accentColor'),
accentColor: ({ theme }) => theme('backgroundColor'),
backgroundColor: ({ theme }) => theme('colors'),
},
},
},
])
expect(config).toMatchObject({
theme: {
colors: {
red: 'red',
green: 'green',
blue: 'blue',
},
accentColor: {
red: 'red',
green: 'green',
blue: 'blue',
},
backgroundColor: {
red: 'red',
green: 'green',
blue: 'blue',
},
caretColor: {
red: 'red',
green: 'green',
blue: 'blue',
},
},
})
})

View file

@ -0,0 +1,228 @@
import { segment } from '../../utils/segment'
import { deepMerge, isPlainObject } from './deep-merge'
import {
type LoadedPlugin,
type LoadedPreset,
type RawPlugin,
type ResolvedConfig,
type ResolvedContentConfig,
type ResolvedThemeValue,
type ThemeValue,
type UserConfig,
} from './types'
interface ResolutionContext {
configs: UserConfig[]
plugins: RawPlugin[]
content: ResolvedContentConfig
theme: Record<string, ThemeValue>
extend: Record<string, ThemeValue[]>
result: ResolvedConfig
}
let minimal: ResolvedConfig = {
important: false,
content: {
files: [],
transform: {},
},
blocklist: [],
prefix: '',
separator: ':',
darkMode: 'media',
theme: {},
plugins: [],
}
export function resolveConfig(configs: UserConfig[]): ResolvedConfig {
let ctx: ResolutionContext = {
configs: [],
plugins: [],
content: {
files: [],
transform: {},
},
theme: {},
extend: {},
// Start with a minimal valid, but empty config
result: structuredClone(minimal),
}
// Gather a list of mergable configs
// Gather a final list of plugins
for (let config of configs) {
resolveInternal(config, ctx)
}
// Merge top-level keys
mergeTopLevel(ctx)
// Merge content
mergeContent(ctx)
// Merge themes
mergeTheme(ctx)
return {
...ctx.result,
theme: ctx.theme as ResolvedConfig['theme'],
content: ctx.content,
plugins: ctx.plugins,
}
}
function mergeTopLevel(ctx: ResolutionContext) {
for (let config of ctx.configs) {
Object.assign(ctx.result, config)
}
}
function mergeContent(ctx: ResolutionContext) {
let resolved = ctx.content
for (let config of ctx.configs) {
let content = config.content ?? []
let files = Array.isArray(content) ? content : content.files
let transform = Array.isArray(content) ? {} : content.transform
resolved.files.push(...files)
Object.assign(resolved.transform, transform)
}
}
function mergeThemeExtension(
themeValue: ThemeValue | ThemeValue[],
extensionValue: ThemeValue | ThemeValue[],
) {
// When we have an array of objects, we do want to merge it
if (Array.isArray(themeValue) && isPlainObject(themeValue[0])) {
return themeValue.concat(extensionValue)
}
// When the incoming value is an array, and the existing config is an object, prepend the existing object
if (
Array.isArray(extensionValue) &&
isPlainObject(extensionValue[0]) &&
isPlainObject(themeValue)
) {
return [themeValue, ...extensionValue]
}
// Override arrays (for example for font-families, box-shadows, ...)
if (Array.isArray(extensionValue)) {
return extensionValue
}
// Execute default behaviour
return undefined
}
export interface PluginUtils {
theme(keypath: string, defaultValue?: any): any
}
function createThemeFn(ctx: ResolutionContext, resolveValue: (value: any) => any) {
return function theme(path: string): any {
let parts = segment(path, '.')
let value: any = ctx.theme
for (let key of parts) {
// The value isn't an object which means we can't traverse it
if (typeof value !== 'object' || value === null) {
return null
}
// The path contains a key that doesn't exist
if (!(key in value)) {
return null
}
value = value[key]
}
return resolveValue(value)
}
}
function mergeTheme(ctx: ResolutionContext) {
let api: PluginUtils = {
theme: createThemeFn(ctx, resolveValue),
}
function resolveValue(value: ThemeValue | null | undefined): ResolvedThemeValue {
if (typeof value === 'function') {
return value(api) ?? null
}
return value ?? null
}
for (let config of ctx.configs) {
let theme = config.theme ?? {}
let extend = theme.extend ?? {}
// Shallow merge themes so latest "group" wins
Object.assign(ctx.theme, theme)
// Collect extensions by key so each
// group can be lazily deep merged
for (let key in extend) {
ctx.extend[key] ??= []
ctx.extend[key].push(extend[key])
}
}
// Remove the `extend` key from the theme
// It's only used for merging and should
// not be present in the resolved theme
delete ctx.theme.extend
// Deep merge every `extend` key into the theme
for (let key in ctx.extend) {
let values = [ctx.theme[key], ...ctx.extend[key]]
ctx.theme[key] = () => {
let v = values.map(resolveValue)
let result = deepMerge({}, v, mergeThemeExtension)
return result
}
}
for (let key in ctx.theme) {
ctx.theme[key] = resolveValue(ctx.theme[key])
}
}
function resolveInternal(user: UserConfig, ctx: ResolutionContext): void {
let presets = (user.presets ?? []) as LoadedPreset[]
let plugins = (user.plugins ?? []) as LoadedPlugin[]
// Presets are applied first
// They may define presets, plugins, and configs
for (let preset of presets) {
resolveInternal(preset, ctx)
}
// Plugins are applied next
// They may define presets, plugins, and configs
for (let plugin of plugins) {
if (typeof plugin === 'function') {
ctx.plugins.push(plugin)
} else {
ctx.plugins.push(plugin.handler)
if (plugin.config) {
resolveInternal(plugin.config(), ctx)
}
}
}
// The "user" config is then applied
// This config may actually represent:
// - a preset config
// - a plugin config
// - a user config
ctx.configs.push(user)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
import type { PluginAPI } from '../api'
import type { CorePluginName } from '../generated/core-utils'
import type { PluginUtils } from './utils'
export type ImportableFile = string | [path: string, options: Record<string, any>]
type FilePath = string
type RawFile = { raw: string; extension?: string }
export type RawPreset = Omit<UserConfig, 'content'>
export type Preset = RawPreset | ImportableFile
type PluginConfig = Omit<UserConfig, 'content'>
export type RawPlugin = (api: PluginAPI) => void
export type ConfigurablePlugin = {
handler: RawPlugin
config?: () => PluginConfig
}
export type Plugin = RawPlugin | ConfigurablePlugin | ImportableFile
export type LoadedPreset = RawPreset
export type LoadedPlugin = RawPlugin | ConfigurablePlugin
export type ContentFile = 'auto' | FilePath | RawFile
export type ContentTransform = (content: string) => string
type ContentConfig = ContentFile[] | ResolvedContentConfig
export interface ResolvedContentConfig {
files: ContentFile[]
transform: Record<string, ContentTransform>
}
// DarkMode related config
export type DarkModeConfig =
// Use the `media` query strategy.
| 'media'
// Use the `class` strategy, which requires a `.dark` class on the `html`.
| 'class'
// Use the `class` strategy with a custom class instead of `.dark`.
| ['class', string]
export type { PluginUtils }
export type ResolvableTo<T> = T | ((utils: PluginUtils) => T)
export interface UserConfig {
important?: boolean
content?: ContentConfig
prefix?: string
separator?: ':' | '_'
darkMode?: DarkModeConfig
presets?: Preset[]
plugins?: Plugin[]
theme?: ThemeConfig
corePlugins?: Partial<Record<CorePluginName, boolean>> | CorePluginName[] | boolean
}
export type ThemeValue = ResolvableTo<Record<string, unknown>> | null | undefined
export type ResolvedThemeValue = Record<string, unknown> | null
export type ThemeConfig = Record<string, ThemeValue> & {
extend?: Record<string, ThemeValue>
}
export interface ResolvedConfig {
important: boolean
content: ResolvedContentConfig
blocklist: string[]
prefix: string
separator: ':' | '_'
darkMode: DarkModeConfig
plugins: RawPlugin[]
theme: Record<string, Record<string, unknown>>
corePlugins: Record<CorePluginName, boolean>
}

View file

@ -135,9 +135,11 @@ describe('theme', async () => {
{
theme: {
extend: {
animationDuration: ({ theme }: { theme: (path: string) => any }) => ({
...theme('transitionDuration'),
}),
animationDuration: ({ theme }: { theme: (path: string) => any }) => {
return {
...theme('transitionDuration'),
}
},
},
},
},
@ -152,4 +154,105 @@ describe('theme', async () => {
"
`)
})
test('plugin theme values that support bare values are merged with other values for that theme key', async ({
expect,
}) => {
let input = css`
@tailwind utilities;
@plugin "my-plugin";
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin(
function ({ matchUtilities, theme }) {
matchUtilities(
{
'animate-duration': (value) => ({ 'animation-duration': value }),
},
{
values: theme('animationDuration'),
},
)
},
{
theme: {
extend: {
transitionDuration: {
slow: '800ms',
},
animationDuration: ({ theme }: { theme: (path: string) => any }) => ({
...theme('transitionDuration'),
}),
},
},
},
)
},
})
expect(compiler.build(['animate-duration-316', 'animate-duration-slow']))
.toMatchInlineSnapshot(`
".animate-duration-316 {
animation-duration: 316ms;
}
.animate-duration-slow {
animation-duration: 800ms;
}
"
`)
})
test('theme value functions are resolved correctly regardless of order', async ({ expect }) => {
let input = css`
@tailwind utilities;
@plugin "my-plugin";
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin(
function ({ matchUtilities, theme }) {
matchUtilities(
{
'animate-delay': (value) => ({ 'animation-delay': value }),
},
{
values: theme('animationDelay'),
},
)
},
{
theme: {
extend: {
animationDuration: ({ theme }: { theme: (path: string) => any }) => ({
...theme('transitionDuration'),
}),
animationDelay: ({ theme }: { theme: (path: string) => any }) => ({
...theme('animationDuration'),
}),
transitionDuration: {
slow: '800ms',
},
},
},
},
)
},
})
expect(compiler.build(['animate-delay-316', 'animate-delay-slow'])).toMatchInlineSnapshot(`
".animate-delay-316 {
animation-delay: 316ms;
}
.animate-delay-slow {
animation-delay: 800ms;
}
"
`)
})
})

View file

@ -1,14 +1,14 @@
import { substituteAtApply } from './apply'
import { objectToAst, rule, type AstNode, type CssInJs } from './ast'
import { resolveConfig } from './compat/config/resolve-config'
import type { UserConfig } from './compat/config/types'
import type { DesignSystem } from './design-system'
import { withAlpha, withNegative } from './utilities'
import { inferDataType } from './utils/infer-data-type'
import { segment } from './utils/segment'
export type Config = Record<string, any>
export type PluginFn = (api: PluginAPI) => void
export type PluginWithConfig = { handler: PluginFn; config?: Partial<Config> }
export type PluginWithConfig = { handler: PluginFn; config?: UserConfig }
export type PluginWithOptions<T> = {
(options?: T): PluginWithConfig
__isOptionsFunction: true
@ -243,35 +243,15 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a
pluginObjects.push(plugin)
} else {
// Just a plain function without using the plugin(…) API
pluginObjects.push({ handler: plugin, config: {} as Config })
pluginObjects.push({ handler: plugin, config: {} as UserConfig })
}
}
// Now merge all the configs and make all that crap work
let resolvedConfig: Config = { theme: {} }
for (let { config } of pluginObjects) {
for (let key in config?.theme?.extend) {
resolvedConfig.theme[key] ??= {}
let value = config.theme.extend[key]
if (value instanceof Function) {
value = value({
theme(path: string) {
if (path === 'transitionDuration') {
return {
[BARE_VALUE]: (value: string) => {
if (!Number.isNaN(Number(value))) {
return `${value}ms`
}
},
}
}
},
})
}
Object.assign(resolvedConfig.theme[key], value)
}
}
let resolvedConfig = resolveConfig([
compatabilityConfig,
...pluginObjects.map(({ config }) => config ?? {}),
])
let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig)
@ -280,3 +260,15 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a
handler(pluginApi)
}
}
export const compatabilityConfig = {
theme: {
transitionDuration: {
[BARE_VALUE]: (value: string) => {
if (!Number.isNaN(Number(value))) {
return `${value}ms`
}
},
},
},
}