From 40914a93a37f4992d072a39adf7a40bb20b6a5e2 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Fri, 16 Aug 2024 16:30:52 -0400 Subject: [PATCH] WIP --- packages/tailwindcss/src/compile.ts | 50 ++-- packages/tailwindcss/src/plugin-api.test.ts | 188 ++++++++++++- packages/tailwindcss/src/plugin-api.ts | 293 +++++++++++--------- packages/tailwindcss/src/sort.test.ts | 2 +- 4 files changed, 376 insertions(+), 157 deletions(-) diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index 80f923e1a..4c935ef8c 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -14,7 +14,7 @@ export function compileCandidates( ) { let nodeSorting = new Map< AstNode, - { properties: number[]; variants: bigint; candidate: string } + { properties: [number[], number[]]; variants: bigint; candidate: string } >() let astNodes: AstNode[] = [] let candidates = new Map() @@ -68,6 +68,9 @@ export function compileCandidates( let aSorting = nodeSorting.get(a)! let zSorting = nodeSorting.get(z)! + let [aProperties, aCounts] = aSorting.properties + let [zProperties, zCounts] = zSorting.properties + // Sort by variant order first if (aSorting.variants - zSorting.variants !== 0n) { return Number(aSorting.variants - zSorting.variants) @@ -76,21 +79,29 @@ export function compileCandidates( // Find the first property that is different between the two rules let offset = 0 while ( - aSorting.properties.length < offset && - zSorting.properties.length < offset && - aSorting.properties[offset] === zSorting.properties[offset] + aProperties.length < offset && + zProperties.length < offset && + aProperties[offset] === zProperties[offset] ) { offset += 1 } - return ( - // Sort by lowest property index first - (aSorting.properties[offset] ?? Infinity) - (zSorting.properties[offset] ?? Infinity) || - // Sort by most properties first, then by least properties - zSorting.properties.length - aSorting.properties.length || - // Sort alphabetically - compare(aSorting.candidate, zSorting.candidate) - ) + // Sort by lowest property index first + let lowestPropertyDelta = (aProperties[offset] ?? Infinity) - (zProperties[offset] ?? Infinity) + if (lowestPropertyDelta) return lowestPropertyDelta + + // Sort by most properties first, then by least properties + let uniquePropertyDelta = zProperties.length - aProperties.length + if (uniquePropertyDelta) return uniquePropertyDelta + + // If both have the same unique properties, sort based on instances of those properties + for (let i = 0; i < aProperties.length; i++) { + let delta = zCounts[i] - aCounts[i] + if (delta) return delta + } + + // Sort alphabetically + return compare(aSorting.candidate, zSorting.candidate) }) return { @@ -243,9 +254,9 @@ function applyImportant(ast: AstNode[]): void { } } -function getPropertySort(nodes: AstNode[]) { +function getPropertySort(nodes: AstNode[]): [number[], number[]] { // Determine sort order based on properties used - let propertySort = new Set() + let propertySort = new Map() let q: AstNode[] = nodes.slice() while (q.length > 0) { @@ -256,13 +267,13 @@ function getPropertySort(nodes: AstNode[]) { if (node.property === '--tw-sort') { let idx = GLOBAL_PROPERTY_ORDER.indexOf(node.value) if (idx !== -1) { - propertySort.add(idx) + propertySort.set(idx, (propertySort.get(idx) ?? 0) + 1) break } } let idx = GLOBAL_PROPERTY_ORDER.indexOf(node.property) - if (idx !== -1) propertySort.add(idx) + if (idx !== -1) propertySort.set(idx, (propertySort.get(idx) ?? 0) + 1) } else if (node.kind === 'rule') { // Don't consider properties within `@at-root` when determining the sort // order for a rule. @@ -274,5 +285,10 @@ function getPropertySort(nodes: AstNode[]) { } } - return Array.from(propertySort).sort((a, z) => a - z) + let sorted = Array.from(propertySort).sort(([a, _a], [z, _z]) => a - z) + + return [ + sorted.map(([propertySort]) => propertySort), + sorted.map(([, propertyCount]) => propertyCount), + ] } diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 2177633e8..d6d07996b 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -1,6 +1,8 @@ -import { describe, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { compile } from '.' import plugin from './plugin' +import type { PluginAPI } from './plugin-api' +import { optimizeCss } from './test-utils/run' const css = String.raw @@ -721,3 +723,187 @@ describe('theme', async () => { `) }) }) + +describe('matchComponents', async () => { + test('example typography plugin', async () => { + async function run(candidates: string[]) { + let compiled = await compile( + css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --font-size-sm: 14px; + --font-size-sm--line-height: 18px; + --spacing-8: 32px; + --font-weight-bold: 700; + --radius-lg: 16px; + } + `, + { + async loadPlugin() { + return plugin( + ({ addComponents, theme }: PluginAPI) => { + addComponents({ + '.prose': theme('typography.base.css'), + }) + }, + { + theme: { + typography: { + base: { + css: { + fontSize: '16px', + lineHeight: '24px', + '* + *': { + marginTop: '32px', + }, + h1: { + fontSize: '32px', + lineHeight: '36px', + }, + h2: { + fontSize: '24px', + lineHeight: '28px', + }, + img: { + borderRadius: '12px', + }, + }, + }, + }, + }, + }, + ) + }, + }, + ) + + return compiled.build(candidates) + } + + expect(optimizeCss(await run(['font-bold', 'text-sm', 'rounded-lg', 'mt-8', 'prose'])).trim()) + .toMatchInlineSnapshot(` + ".prose { + font-size: 16px; + line-height: 24px; + } + + .prose * + * { + margin-top: 32px; + } + + .prose h1 { + font-size: 32px; + line-height: 36px; + } + + .prose h2 { + font-size: 24px; + line-height: 28px; + } + + .prose img { + border-radius: 12px; + } + + .mt-8 { + margin-top: var(--spacing-8, 32px); + } + + .rounded-lg { + border-radius: var(--radius-lg, 16px); + } + + .text-sm { + font-size: var(--font-size-sm, 14px); + line-height: var(--font-size-sm--line-height, 18px); + } + + .font-bold { + font-weight: var(--font-weight-bold, 700); + }" + `) + }) + + test('example container plugin', async () => { + async function run(candidates: string[]) { + let compiled = await compile( + css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --width-sm: 24rem; + } + `, + { + async loadPlugin() { + return plugin(({ addComponents }: PluginAPI) => { + addComponents({ + '.zontainer': { + maxWidth: '100%', + '@media (min-width: 640px)': { + maxWidth: '640px', + }, + '@media (min-width: 768px)': { + maxWidth: '768px', + }, + '@media (min-width: 1024px)': { + maxWidth: '1024px', + }, + '@media (min-width: 1280px)': { + maxWidth: '1280px', + }, + '@media (min-width: 1536px)': { + maxWidth: '1536px', + }, + }, + }) + }) + }, + }, + ) + + return compiled.build(candidates) + } + + expect(optimizeCss(await run(['max-w-sm', 'zontainer'])).trim()).toMatchInlineSnapshot(` + ".zontainer { + max-width: 100%; + } + + @media (width >= 640px) { + .zontainer { + max-width: 640px; + } + } + + @media (width >= 768px) { + .zontainer { + max-width: 768px; + } + } + + @media (width >= 1024px) { + .zontainer { + max-width: 1024px; + } + } + + @media (width >= 1280px) { + .zontainer { + max-width: 1280px; + } + } + + @media (width >= 1536px) { + .zontainer { + max-width: 1536px; + } + } + + .max-w-sm { + max-width: var(--width-sm, 24rem); + }" + `) + }) +}) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 143765321..71399dba8 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -38,6 +38,20 @@ export type PluginAPI = { }>, ): void theme(path: string): any + addComponents(components: Record, options?: {}): void + matchComponents( + utilities: Record CssInJs>, + options?: Partial<{ + type: string | string[] + supportsNegativeValues: boolean + values: { __BARE_VALUE__?: (value: NamedUtilityValue) => string | undefined } & Record< + string, + string + > + modifiers: 'any' | Record + }>, + ): void + theme(path: string): any } const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ @@ -47,6 +61,143 @@ export function buildPluginApi( ast: AstNode[], resolvedConfig: { theme?: Record }, ): PluginAPI { + let addUtilities: PluginAPI['addUtilities'] = (utilities) => { + for (let [name, css] of Object.entries(utilities)) { + if (name.startsWith('@keyframes ')) { + ast.push(rule(name, objectToAst(css))) + continue + } + + if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) { + throw new Error( + `\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`, + ) + } + + designSystem.utilities.static(name.slice(1), (candidate) => { + if (candidate.negative) return + + let ast = objectToAst(css) + substituteAtApply(ast, designSystem) + return ast + }) + } + } + + let matchUtilities: PluginAPI['matchUtilities'] = (utilities, options) => { + let types = options?.type + ? Array.isArray(options?.type) + ? options.type + : [options.type] + : ['any'] + + for (let [name, fn] of Object.entries(utilities)) { + if (!IS_VALID_UTILITY_NAME.test(name)) { + throw new Error( + `\`matchUtilities({ '${name}' : … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`, + ) + } + + designSystem.utilities.functional(name, (candidate) => { + // A negative utility was provided but is unsupported + if (!options?.supportsNegativeValues && candidate.negative) return + + // Throw out any candidate whose value is not a supported type + if (candidate.value?.kind === 'arbitrary' && types.length > 0 && !types.includes('any')) { + // The candidate has an explicit data type but it's not in the list + // of supported types by this utility. For example, a `scrollbar` + // utility that is only used to change the scrollbar color but is + // used with a `length` value: `scrollbar-[length:var(--whatever)]` + if (candidate.value.dataType && !types.includes(candidate.value.dataType)) { + return + } + + // The candidate does not have an explicit data type and the value + // cannot be inferred as one of the supported types. For example, a + // `scrollbar` utility that is only used to change the scrollbar + // color but is used with a `length` value: `scrollbar-[33px]` + if (!candidate.value.dataType && !inferDataType(candidate.value.value, types as any[])) { + return + } + } + + let isColor = types.includes('color') + + // Resolve the candidate value + let value: string | null = null + let isFraction = false + + { + let values = options?.values ?? {} + + if (isColor) { + // Color utilities implicitly support `inherit`, `transparent`, and `currentColor` + // for backwards compatibility but still allow them to be overridden + values = Object.assign( + { + inherit: 'inherit', + transparent: 'transparent', + current: 'currentColor', + }, + values, + ) + } + + if (!candidate.value) { + value = values.DEFAULT ?? null + } else if (candidate.value.kind === 'arbitrary') { + value = candidate.value.value + } else if (values[candidate.value.value]) { + value = values[candidate.value.value] + } else if (values.__BARE_VALUE__) { + value = values.__BARE_VALUE__(candidate.value) ?? null + + isFraction = (candidate.value.fraction !== null && value?.includes('/')) ?? false + } + } + + if (value === null) return + + // Resolve the modifier value + let modifier: string | null + + { + let modifiers = options?.modifiers ?? null + + if (!candidate.modifier) { + modifier = null + } else if (modifiers === 'any' || candidate.modifier.kind === 'arbitrary') { + modifier = candidate.modifier.value + } else if (modifiers?.[candidate.modifier.value]) { + modifier = modifiers[candidate.modifier.value] + } else if (isColor && !Number.isNaN(Number(candidate.modifier.value))) { + modifier = `${candidate.modifier.value}%` + } else { + modifier = null + } + } + + // A modifier was provided but is invalid + if (candidate.modifier && modifier === null && !isFraction) { + // For arbitrary values, return `null` to avoid falling through to the next utility + return candidate.value?.kind === 'arbitrary' ? null : undefined + } + + if (isColor && modifier !== null) { + value = withAlpha(value, modifier) + } + + if (candidate.negative) { + value = withNegative(value, candidate) + } + + let ast = objectToAst(fn(value, { modifier })) + substituteAtApply(ast, designSystem) + return ast + }) + } + } + return { addBase(css) { ast.push(rule('@layer base', objectToAst(css))) @@ -73,145 +224,11 @@ export function buildPluginApi( } }, - addUtilities(utilities) { - for (let [name, css] of Object.entries(utilities)) { - if (name.startsWith('@keyframes ')) { - ast.push(rule(name, objectToAst(css))) - continue - } + addUtilities, + matchUtilities, + addComponents: addUtilities, + matchComponents: matchUtilities, - if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) { - throw new Error( - `\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`, - ) - } - - designSystem.utilities.static(name.slice(1), (candidate) => { - if (candidate.negative) return - - let ast = objectToAst(css) - substituteAtApply(ast, designSystem) - return ast - }) - } - }, - - matchUtilities(utilities, options) { - let types = options?.type - ? Array.isArray(options?.type) - ? options.type - : [options.type] - : ['any'] - - for (let [name, fn] of Object.entries(utilities)) { - if (!IS_VALID_UTILITY_NAME.test(name)) { - throw new Error( - `\`matchUtilities({ '${name}' : … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`, - ) - } - - designSystem.utilities.functional(name, (candidate) => { - // A negative utility was provided but is unsupported - if (!options?.supportsNegativeValues && candidate.negative) return - - // Throw out any candidate whose value is not a supported type - if (candidate.value?.kind === 'arbitrary' && types.length > 0 && !types.includes('any')) { - // The candidate has an explicit data type but it's not in the list - // of supported types by this utility. For example, a `scrollbar` - // utility that is only used to change the scrollbar color but is - // used with a `length` value: `scrollbar-[length:var(--whatever)]` - if (candidate.value.dataType && !types.includes(candidate.value.dataType)) { - return - } - - // The candidate does not have an explicit data type and the value - // cannot be inferred as one of the supported types. For example, a - // `scrollbar` utility that is only used to change the scrollbar - // color but is used with a `length` value: `scrollbar-[33px]` - if ( - !candidate.value.dataType && - !inferDataType(candidate.value.value, types as any[]) - ) { - return - } - } - - let isColor = types.includes('color') - - // Resolve the candidate value - let value: string | null = null - let isFraction = false - - { - let values = options?.values ?? {} - - if (isColor) { - // Color utilities implicitly support `inherit`, `transparent`, and `currentColor` - // for backwards compatibility but still allow them to be overridden - values = Object.assign( - { - inherit: 'inherit', - transparent: 'transparent', - current: 'currentColor', - }, - values, - ) - } - - if (!candidate.value) { - value = values.DEFAULT ?? null - } else if (candidate.value.kind === 'arbitrary') { - value = candidate.value.value - } else if (values[candidate.value.value]) { - value = values[candidate.value.value] - } else if (values.__BARE_VALUE__) { - value = values.__BARE_VALUE__(candidate.value) ?? null - - isFraction = (candidate.value.fraction !== null && value?.includes('/')) ?? false - } - } - - if (value === null) return - - // Resolve the modifier value - let modifier: string | null - - { - let modifiers = options?.modifiers ?? null - - if (!candidate.modifier) { - modifier = null - } else if (modifiers === 'any' || candidate.modifier.kind === 'arbitrary') { - modifier = candidate.modifier.value - } else if (modifiers?.[candidate.modifier.value]) { - modifier = modifiers[candidate.modifier.value] - } else if (isColor && !Number.isNaN(Number(candidate.modifier.value))) { - modifier = `${candidate.modifier.value}%` - } else { - modifier = null - } - } - - // A modifier was provided but is invalid - if (candidate.modifier && modifier === null && !isFraction) { - // For arbitrary values, return `null` to avoid falling through to the next utility - return candidate.value?.kind === 'arbitrary' ? null : undefined - } - - if (isColor && modifier !== null) { - value = withAlpha(value, modifier) - } - - if (candidate.negative) { - value = withNegative(value, candidate) - } - - let ast = objectToAst(fn(value, { modifier })) - substituteAtApply(ast, designSystem) - return ast - }) - } - }, theme(path) { let cssValue = readFromCss(designSystem.theme, path) diff --git a/packages/tailwindcss/src/sort.test.ts b/packages/tailwindcss/src/sort.test.ts index 992c1d141..ca24fcf7d 100644 --- a/packages/tailwindcss/src/sort.test.ts +++ b/packages/tailwindcss/src/sort.test.ts @@ -68,7 +68,7 @@ test('can sort classes deterministically across multiple class lists', async () } }) -test('sorts arbitrary values across one or more class lists consistently', async () => { +test.only('sorts arbitrary values across one or more class lists consistently', async () => { let classes = [ ['[--fg:#fff]', '[--fg:#fff]'], ['[--bg:#111] [--bg_hover:#000] [--fg:#fff]', '[--bg:#111] [--bg_hover:#000] [--fg:#fff]'],