Work on matchUtilities API

This commit is contained in:
Jordan Pittman 2024-07-24 16:01:23 -04:00
parent f0e9343b5b
commit 1a1d0dffb2
3 changed files with 631 additions and 1 deletions

View file

@ -15,12 +15,44 @@ import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme } from './theme'
import { withNegative } from './utilities'
import { inferDataType } from './utils/infer-data-type'
import { segment } from './utils/segment'
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
const IS_VALID_UTILITY_SELECTOR = /^\.[a-z][a-zA-Z0-9/%._-]*$/
type PluginAPI = {
addVariant(name: string, variant: string | string[] | CssInJs): void
addUtilities(
utilities: Record<string, CssInJs>,
options?: Partial<{
// todo: maybe not necessary any more
// respectPrefix: boolean
// todo: needed??
respectImportant: boolean
}>,
): void
matchUtilities(
utilities: Record<string, (value: string, extra: { modifier: string | null }) => CssInJs>,
options?: Partial<{
type: string | string[]
// todo: maybe not necessary any more
// respectPrefix: boolean
// todo: needed??
respectImportant: boolean
supportsNegativeValues: boolean
values: Record<string, string>
modifiers: 'any' | Record<string, string>
}>,
): void
theme(path: string, fallback?: any): any
}
type Plugin = (api: PluginAPI) => void
@ -280,6 +312,128 @@ export function compile(
designSystem.variants.fromAst(name, objectToAst(variant))
}
},
addUtilities(utilities) {
for (let [name, css] of Object.entries(utilities)) {
if (!IS_VALID_UTILITY_SELECTOR.test(name)) {
throw new Error(
`\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities are a single class that is alphanumeric and starts with a lowercase letter.`,
)
}
designSystem.utilities.static(name.slice(1), (candidate) => {
if (candidate.negative) return
return objectToAst(css)
})
}
},
matchUtilities(utilities, options) {
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.`,
)
}
designSystem.utilities.functional(name, (candidate) => {
if (!options?.supportsNegativeValues) {
if (candidate.negative) return
}
if (candidate.modifier && !options?.modifiers) return
let modifier: string | null = null
if (options?.modifiers === 'any') {
modifier = candidate.modifier?.value ?? null
} else if (options?.modifiers && candidate.modifier) {
if (candidate.modifier.kind === 'arbitrary') {
modifier = candidate.modifier.value
} else {
modifier = options.modifiers[candidate.modifier.value] ?? null
if (modifier === null) return
}
}
// TODO: DEFAULT ??
if (!candidate.value) {
let value = options?.values?.DEFAULT ?? null
if (!value) return
if (candidate.negative) {
value = withNegative(value, candidate)
}
return objectToAst(fn(value, { modifier }))
}
if (candidate.value.kind === 'arbitrary') {
let value = candidate.value.value
let types = options?.type
? Array.isArray(options?.type)
? options.type
: [options.type]
: []
if (types.length > 0) {
if (candidate.value.dataType) {
if (!types.includes(candidate.value.dataType) && !types.includes('any')) return
} else {
let dataType = inferDataType(value, types as any[])
if (!dataType) return
}
}
if (candidate.negative) {
value = withNegative(value, candidate)
}
return objectToAst(fn(value, { modifier }))
}
// Look up in values: {…}
let value = options?.values?.[candidate.value.value] ?? null
if (!value) return
if (candidate.negative) {
value = withNegative(value, candidate)
}
return objectToAst(fn(value, { modifier }))
})
}
},
theme(path: string, fallback?: any) {
path = path
// 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
path = `--${path}`
let value =
theme.resolveValue(null, [path] as any) ?? theme.namespace(path as any) ?? fallback
console.log({
path,
value,
ns: theme.namespace(path as any),
tg: theme.get(path as any),
trv: theme.resolveValue(null, [path] as any),
})
if (value && typeof value === 'object' && value instanceof Map) {
return Object.fromEntries(value.entries())
}
return value
},
}
for (let plugin of plugins) {

View file

@ -15150,3 +15150,479 @@ describe('custom utilities', () => {
).toThrowError(/should be alphanumeric/)
})
})
describe('legacy: addUtilities', () => {
test('custom static utility', () => {
let compiled = compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
@theme reference {
--breakpoint-lg: 1024px;
},
`,
{
loadPlugin() {
return ({ addUtilities }) => {
addUtilities({
'.text-trim': {
'text-box-trim': 'both',
'text-box-edge': 'cap alphabetic',
},
})
}
},
},
).build(['text-trim', 'lg:text-trim'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.text-trim {
text-box-trim: both;
text-box-edge: cap alphabetic;
}
@media (width >= 1024px) {
.lg\\:text-trim {
text-box-trim: both;
text-box-edge: cap alphabetic;
}
}
}"
`)
})
test('throws on custom static utilities with an invalid name', () => {
expect(() => {
return compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
@theme reference {
--breakpoint-lg: 1024px;
},
`,
{
loadPlugin() {
return ({ addUtilities }) => {
addUtilities({
'.text-trim > *': {
'text-box-trim': 'both',
'text-box-edge': 'cap alphabetic',
},
})
}
},
},
)
}).toThrowError(/invalid utility selector/)
})
})
describe('legacy: matchUtilities', () => {
test('custom functional utility', () => {
function run(candidates: string[]) {
return compile(
css`
@plugin "my-plugin";
@tailwind utilities;
@theme reference {
--breakpoint-lg: 1024px;
},
`,
{
loadPlugin() {
return ({ matchUtilities }) => {
matchUtilities(
{
'border-block': (value) => {
return {
'border-block-width': value,
}
},
},
{
values: {
DEFAULT: '1px',
'2': '2px',
},
},
)
}
},
},
).build(candidates)
}
expect(
optimizeCss(
run(['border-block', 'border-block-2', 'border-block-[35px]', 'lg:border-block-2']),
).trim(),
).toMatchInlineSnapshot(`
".border-block {
border-block-width: 1px;
}
.border-block-2 {
border-block-width: 2px;
}
.border-block-\\[35px\\] {
border-block-width: 35px;
}
@media (width >= 1024px) {
.lg\\:border-block-2 {
border-block-width: 2px;
}
}"
`)
expect(
optimizeCss(
run([
'-border-block',
'-border-block-2',
'lg:-border-block-2',
'border-block-unknown',
'border-block/1',
]),
).trim(),
).toEqual('')
})
test('custom functional utility with any modifier', () => {
function run(candidates: string[]) {
return compile(
css`
@plugin "my-plugin";
@tailwind utilities;
@theme reference {
--breakpoint-lg: 1024px;
},
`,
{
loadPlugin() {
return ({ matchUtilities }) => {
matchUtilities(
{
'border-block': (value, { modifier }) => {
return {
'--my-modifier': modifier ?? 'none',
'border-block-width': value,
}
},
},
{
values: {
DEFAULT: '1px',
'2': '2px',
},
modifiers: 'any',
},
)
}
},
},
).build(candidates)
}
expect(
optimizeCss(
run(['border-block', 'border-block-2', 'border-block/foo', 'border-block-2/foo']),
).trim(),
).toMatchInlineSnapshot(`
".border-block {
--my-modifier: none;
border-block-width: 1px;
}
.border-block-2 {
--my-modifier: none;
border-block-width: 2px;
}
.border-block-2\\/foo {
--my-modifier: foo;
border-block-width: 2px;
}
.border-block\\/foo {
--my-modifier: foo;
border-block-width: 1px;
}"
`)
})
test('custom functional utility with known modifier', () => {
function run(candidates: string[]) {
return compile(
css`
@plugin "my-plugin";
@tailwind utilities;
@theme reference {
--breakpoint-lg: 1024px;
},
`,
{
loadPlugin() {
return ({ matchUtilities }) => {
matchUtilities(
{
'border-block': (value, { modifier }) => {
return {
'--my-modifier': modifier ?? 'none',
'border-block-width': value,
}
},
},
{
values: {
DEFAULT: '1px',
'2': '2px',
},
modifiers: {
foo: 'foo',
},
},
)
}
},
},
).build(candidates)
}
expect(
optimizeCss(
run([
'border-block',
'border-block-2',
'border-block/foo',
'border-block-2/foo',
'border-block/unknown',
'border-block-2/unknown',
]),
).trim(),
).toMatchInlineSnapshot(`
".border-block {
--my-modifier: none;
border-block-width: 1px;
}
.border-block-2 {
--my-modifier: none;
border-block-width: 2px;
}
.border-block-2\\/foo {
--my-modifier: foo;
border-block-width: 2px;
}
.border-block\\/foo {
--my-modifier: foo;
border-block-width: 1px;
}"
`)
})
test('throws on custom static utilities with an invalid name', () => {
expect(() => {
return compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
@theme reference {
--breakpoint-lg: 1024px;
},
`,
{
loadPlugin() {
return ({ matchUtilities }) => {
matchUtilities({
'.text-trim > *': () => ({
'text-box-trim': 'both',
'text-box-edge': 'cap alphabetic',
}),
})
}
},
},
)
}).toThrowError(/invalid utility name/)
})
test('custom functional utilities with different types', () => {
function run(candidates: string[]) {
return compile(
css`
@plugin "my-plugin";
@tailwind utilities;
@theme reference {
--breakpoint-lg: 1024px;
},
`,
{
loadPlugin() {
return ({ matchUtilities }) => {
matchUtilities(
{
scrollbar: (value) => {
return {
'scrollbar-color': value,
}
},
},
{
type: ['color'],
values: {
black: 'black',
},
},
)
matchUtilities(
{
scrollbar: (value) => {
return {
'scrollbar-width': value,
}
},
},
{
type: ['length'],
values: {
2: '2px',
},
},
)
}
},
},
).build(candidates)
}
expect(
optimizeCss(
run([
'scrollbar-black',
'scrollbar-2',
'scrollbar-[#fff]',
'scrollbar-[2px]',
'scrollbar-[color:var(--my-color)]',
'scrollbar-[length:var(--my-width)]',
]),
).trim(),
).toMatchInlineSnapshot(`
".scrollbar-2 {
scrollbar-width: 2px;
}
.scrollbar-\\[\\#fff\\] {
scrollbar-color: #fff;
}
.scrollbar-\\[2px\\] {
scrollbar-width: 2px;
}
.scrollbar-\\[color\\:var\\(--my-color\\)\\] {
scrollbar-color: var(--my-color);
}
.scrollbar-\\[length\\:var\\(--my-width\\)\\] {
scrollbar-width: var(--my-width);
}
.scrollbar-black {
scrollbar-color: black;
}"
`)
})
test.skip('custom utility that reads from the theme', () => {
function run(candidates: string[]) {
return compile(
css`
@plugin "my-plugin";
@tailwind utilities;
@theme reference {
--scrollbar-big: 20px;
},
`,
{
loadPlugin() {
return ({ matchUtilities, theme }) => {
matchUtilities(
{
scrollbar: (value, { modifier }) => {
return {
'--my-modifier': modifier ?? 'none',
'border-block-width': value,
}
},
},
{
values: theme('scrollbar'),
},
)
}
},
},
).build(candidates)
}
expect(optimizeCss(run(['scrollbar-big'])).trim()).toMatchInlineSnapshot()
})
test('custom utility that reads from the theme', () => {
expect.hasAssertions()
compile(
css`
@plugin "my-plugin";
@tailwind utilities;
@theme reference {
--scrollbar-big: 20px;
--scrollbar-big-properties: auto-hidden;
},
`,
{
loadPlugin() {
return ({ theme }) => {
expect(theme('scrollbar')).toEqual({
big: '20px',
'big-properties': 'auto-hidden',
})
expect(theme('scrollbar.big')).toEqual('20px')
expect(theme('scrollbar.big.properties')).toEqual('auto-hidden')
}
},
},
)
})
})

View file

@ -159,7 +159,7 @@ function asColor(value: string, modifier: CandidateModifier | null, theme: Theme
/**
* Negate a numeric value — literals get simplified by Lightning CSS.
*/
function withNegative(
export function withNegative(
value: string,
candidate: Extract<Candidate, { kind: 'static' | 'functional' }>,
) {