import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, test } from 'vitest'
import { __unstable__loadDesignSystem } from '.'
import { cartesian } from './cartesian'
import type { CanonicalizeOptions } from './intellisense'
import { DefaultMap } from './utils/default-map'
const css = String.raw
const timeout = 25_000
const defaultTheme = fs.readFileSync(path.resolve(__dirname, '../theme.css'), 'utf8')
const designSystems = new DefaultMap((base: string) => {
return new DefaultMap((input: string) => {
return __unstable__loadDesignSystem(input, {
base,
async loadStylesheet() {
return {
path: '',
base: '',
content: css`
@tailwind utilities;
${defaultTheme}
@theme {
--*: initial;
--breakpoint-lg: 64rem;
--breakpoint-md: 48rem;
--color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-white: #fff;
--container-md: 28rem;
--font-weight-normal: 400;
--leading-relaxed: 1.625;
--spacing: 0.25rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-sm: 0.875rem;
--text-xs--line-height: calc(1 / 0.75);
--text-xs: 0.75rem;
}
`,
}
},
})
})
})
const DEFAULT_CANONICALIZATION_OPTIONS: CanonicalizeOptions = {
rem: 16,
collapse: true,
logicalToPhysical: true,
}
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
let testName = '%s → %s (%#)'
if (strategy === 'with-variant') {
testName = testName.replaceAll('%s', 'focus:%s')
} else if (strategy === 'important') {
testName = testName.replaceAll('%s', '%s!')
} else if (strategy === 'prefix') {
testName = testName.replaceAll('%s', 'tw:%s')
}
function prepare(candidate: string) {
if (strategy === 'with-variant') {
candidate = `focus:${candidate}`
} else if (strategy === 'important') {
candidate = `${candidate}!`
} else if (strategy === 'prefix') {
candidate = `tw:${candidate}`
if (candidate.includes('--')) {
candidate = candidate
.replace(
/([(:])--([\w-]+)([,)])/g,
(_, start, variable, end) => `${start}--tw-${variable}${end}`,
)
.replaceAll('--theme(--tw-', '--theme(--')
}
}
return candidate
}
async function expectCanonicalization(
input: string,
candidate: string,
expected: string,
options: CanonicalizeOptions = DEFAULT_CANONICALIZATION_OPTIONS,
) {
candidate = prepare(candidate)
expected = prepare(expected)
if (strategy === 'prefix') {
input = input.replace("@import 'tailwindcss';", "@import 'tailwindcss' prefix(tw);")
}
let designSystem = await designSystems.get(__dirname).get(input)
let [actual] = designSystem.canonicalizeCandidates([candidate], options)
try {
expect(actual).toBe(expected)
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, expectCanonicalization)
throw err
}
}
async function expectCombinedCanonicalization(
input: string,
candidates: string,
expected: string,
options: CanonicalizeOptions = DEFAULT_CANONICALIZATION_OPTIONS,
) {
let preparedCandidates = candidates.split(/\s+/g).map(prepare)
let preparedExpected = expected.split(/\s+/g).map(prepare)
if (strategy === 'prefix') {
input = input.replace("@import 'tailwindcss';", "@import 'tailwindcss' prefix(tw);")
}
let designSystem = await designSystems.get(__dirname).get(input)
let actual = designSystem.canonicalizeCandidates(preparedCandidates, options)
try {
expect(actual).toEqual(preparedExpected)
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, expectCombinedCanonicalization)
throw err
}
}
test.each([
['bg-gradient-to-t', 'bg-linear-to-t'],
['bg-gradient-to-tr', 'bg-linear-to-tr'],
['bg-gradient-to-r', 'bg-linear-to-r'],
['bg-gradient-to-br', 'bg-linear-to-br'],
['bg-gradient-to-b', 'bg-linear-to-b'],
['bg-gradient-to-bl', 'bg-linear-to-bl'],
['bg-gradient-to-l', 'bg-linear-to-l'],
['bg-gradient-to-tl', 'bg-linear-to-tl'],
['[color:red]', 'text-[red]'],
['[--value:theme(spacing.1)]', '[--value:--spacing(1)]'],
['[--value:theme(fontSize.xs.1.lineHeight)]', '[--value:var(--text-xs--line-height)]'],
['[--value:theme(spacing[1.25])]', '[--value:--spacing(1.25)]'],
['[--value:theme(spacing[1.1])]', '[--value:theme(spacing[1.1])]'],
['[color:theme(colors.red.500)]', 'text-red-500'],
['[color:theme(colors.red.500)]/50', 'text-red-500/50'],
['bg-[theme(colors.red.500)]', 'bg-red-500'],
['bg-[size:theme(spacing.4)]', 'bg-size-[--spacing(4)]'],
['w-[calc(100dvh-theme(spacing.2))]', 'w-[calc(100dvh-(--spacing(2)))]'],
['w-[calc(100dvh+theme(spacing.2))]', 'w-[calc(100dvh+(--spacing(2)))]'],
['w-[calc(100dvh/theme(spacing.2))]', 'w-[calc(100dvh/(--spacing(2)))]'],
['w-[calc(100dvh*theme(spacing.2))]', 'w-[calc(100dvh*(--spacing(2)))]'],
['bg-[theme(colors.red.500,red)]', 'bg-(--color-red-500,red)'],
['bg-[theme(colors.foo.1000)]', 'bg-[theme(colors.foo.1000)]'],
['bg-[theme(colors.foo.1000,theme(colors.red.500))]', 'bg-red-500'],
['text-[calc(theme(fontSize.xs)*2)]', 'text-[calc(var(--text-xs)*2)]'],
[
'[color:theme(colors.red.500/50,theme(colors.blue.500/50))]',
'text-[--theme(--color-red-500/50,--theme(--color-blue-500/50))]',
],
[
'[color:theme(colors.red.500/50,theme(colors.blue.500/50))]/50',
'text-[--theme(--color-red-500/50,--theme(--color-blue-500/50))]/50',
],
['[color:theme(colors.red.500/75%)]', 'text-red-500/75'],
['[color:theme(colors.red.500/.12)]', 'text-red-500/12'],
['[color:theme(colors.red.500/0.12)]', 'text-red-500/12'],
['[color:theme(colors.red.500/12.34%)]', 'text-red-500/[12.34%]'],
['[color:theme(colors.red.500/var(--opacity))]', 'text-red-500/(--opacity)'],
['[color:theme(colors.red.500/.12345)]', 'text-red-500/1234.5'],
['[color:theme(colors.red.500/50.25%)]', 'text-red-500/50.25'],
['bg-[theme(colors.red.500/75%)]', 'bg-red-500/75'],
['bg-[theme(colors.red.500/12.34%)]', 'bg-red-500/[12.34%]'],
['[color:theme(colors.red.500/50%)]/50', 'text-[--theme(--color-red-500/50%)]/50'],
[
'shadow-[shadow:inset_0px_1px_theme(colors.white/15%)]',
'shadow-[inset_0px_1px_--theme(--color-white/15%)]',
],
['bg-[theme(colors.red.500/50%)]/50', 'bg-[--theme(--color-red-500/50%)]/50'],
['max-[theme(screens.lg)]:flex', 'max-[--theme(--breakpoint-lg)]:flex'],
['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'],
['max-[theme(spacing.4/50)]:flex', 'max-[theme(spacing.4/50)]:flex'],
['grid-cols-[min(50%_,_theme(spacing.80))_auto]', 'grid-cols-[min(50%,--spacing(80))_auto]'],
['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'],
['max-w-[theme(screens.md)]', 'max-w-(--breakpoint-md)'],
['w-[theme(maxWidth.md)]', 'w-md'],
['[--foo:theme(colors.red.500/50/50)]', '[--foo:theme(colors.red.500/50/50)]'],
['[--foo:theme(colors.red.500/50/50)]/50', '[--foo:theme(colors.red.500/50/50)]/50'],
[
'[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]',
'[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]',
],
[
'[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]/50',
'[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]/50',
],
['[font-weight:400]', 'font-normal'],
['[line-height:0]', 'leading-0'],
['[border-style:solid]', 'border-solid'],
])(testName, { timeout }, async (candidate, expected) => {
await expectCanonicalization(
css`
@import 'tailwindcss';
`,
candidate,
expected,
)
})
describe('arbitrary utilities', () => {
test.each([
['[text-wrap:balance]', 'text-balance'],
['[display:_flex_]', 'flex'],
['[display:_flex]', 'flex'],
['[display:flex_]', 'flex'],
['leading-[1]', 'leading-none'],
['[color:var(--color-red-500)]', 'text-red-500'],
['[background-color:var(--color-red-500)]', 'bg-red-500'],
['[color:var(--color-red-500)]/25', 'text-red-500/25'],
['[color:var(--color-red-500)]/[25%]', 'text-red-500/25'],
['[color:var(--color-red-500)]/[100%]', 'text-red-500'],
['[color:var(--color-red-500)]/100', 'text-red-500'],
['[color:var(--color-red-500)]/[10%]', 'text-red-500/10'],
['[color:var(--color-red-500)]/[10.0%]', 'text-red-500/10'],
['[color:var(--color-red-500)]/[.1]', 'text-red-500/10'],
['[color:var(--color-red-500)]/[.10]', 'text-red-500/10'],
['[color:oklch(62.3%_0.214_259.815)]/50', 'text-primary'],
['[max-height:20%]', 'max-h-[20%]'],
['[grid-column:2]', 'col-2'],
['[grid-column:1234]', 'col-1234'],
['border-[2px]', 'border-2'],
['border-[1234px]', 'border-1234'],
['bg-[position:123px]', 'bg-position-[123px]'],
['bg-[size:123px]', 'bg-size-[123px]'],
['bg-[123px]', 'bg-position-[123px]'],
['w-[64rem]', 'w-256'],
[
'[grid-template-columns:repeat(2,minmax(100px,1fr))]',
'grid-cols-[repeat(2,minmax(100px,1fr))]',
],
['[grid-template-columns:repeat(2,minmax(0,1fr))]', 'grid-cols-2'],
['from-[25%]', 'from-25%'],
['from-[2.5%]', 'from-[2.5%]'],
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--spacing: 0.25rem;
--color-red-500: red;
--color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent);
}
`
await expectCanonicalization(input, candidate, expected)
})
test('migrate with custom static utility `@utility custom {…}`', { timeout }, async () => {
let candidate = '[--key:value]'
let expected = 'custom'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@utility custom {
--key: value;
}
`
await expectCanonicalization(input, candidate, expected)
})
test(
'migrate with custom functional utility `@utility custom-* {…}`',
{ timeout },
async () => {
let candidate = '[--key:value]'
let expected = 'custom-value'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@utility custom-* {
--key: --value('value');
}
`
await expectCanonicalization(input, candidate, expected)
},
)
test(
'migrate with custom functional utility `@utility custom-* {…}` that supports bare values',
{ timeout },
async () => {
let candidate = '[tab-size:4]'
let expected = 'tab-4'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@utility tab-* {
tab-size: --value(integer);
}
`
await expectCanonicalization(input, candidate, expected)
},
)
test.each([
['[tab-size:0]', 'tab-0'],
['[tab-size:4]', 'tab-4'],
['[tab-size:8]', 'tab-github'],
['tab-[0]', 'tab-0'],
['tab-[4]', 'tab-4'],
['tab-[8]', 'tab-github'],
])(
'migrate custom @utility from arbitrary values to bare values and named values (based on theme)',
async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size, integer, [integer]);
}
`
await expectCanonicalization(input, candidate, expected)
},
)
describe.each([['@theme'], ['@theme inline']])('%s', (theme) => {
test.each([
['[color:CanvasText]', 'text-canvas'],
['text-[CanvasText]', 'text-canvas'],
])(`migrate arbitrary value to theme value ${testName}`, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
${theme} {
--*: initial;
--color-canvas: CanvasText;
}
`
await expectCanonicalization(input, candidate, expected)
})
test.each([
['max-w-(--breakpoint-md)', 'max-w-(--breakpoint-md)'],
['max-w-(--container-3xl)', 'max-w-3xl'],
])(
`migrate arbitrary value to theme value ${testName}`,
{ timeout },
async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
${theme} {
--*: initial;
--breakpoint-md: 48rem;
--container-3xl: 48rem;
}
`
await expectCanonicalization(input, candidate, expected)
},
)
})
test(
'migrate an arbitrary property without spaces, to a theme value with spaces (canonicalization)',
{ timeout },
async () => {
let candidate = 'font-[foo,bar,baz]'
let expected = 'font-example'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--font-example: foo, bar, baz;
}
`
await expectCanonicalization(input, candidate, expected)
},
)
test.each([
['w-[64rem]', 'w-256', '0.25rem'],
['gap-[7.25rem]', 'gap-29', '0.25rem'],
['gap-[calc(7rem+0.25rem)]', 'gap-29', '0.25rem'],
['gap-[116px]', 'gap-29', '0.25rem'],
['gap-[7.25rem]', 'gap-29', '4px'],
['gap-[calc(7rem+0.25rem)]', 'gap-29', '4px'],
['gap-[116px]', 'gap-29', '4px'],
['gap-[7.25rem]', 'gap-116', '1px'],
['gap-[calc(7rem+0.25rem)]', 'gap-116', '1px'],
['gap-[116px]', 'gap-116', '1px'],
['w-[124px]', 'w-31', '0.25rem'],
['w-[0.123rem]', 'w-[0.123rem]', '0.25rem'],
['w-[123px]', 'w-123', '1px'],
['w-[256px]', 'w-128', '2px'],
])(`${testName} (spacing = \`%s\`)`, { timeout }, async (candidate, expected, spacing) => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--spacing: ${spacing};
}
`
await expectCanonicalization(input, candidate, expected)
})
})
describe('bare values', () => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--spacing: 0.25rem;
--aspect-video: 16 / 9;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size, integer);
}
`
test.each([
['aspect-16/9', 'aspect-video'],
['tab-8', 'tab-github'],
])(testName, { timeout }, async (candidate, expected) => {
await expectCanonicalization(input, candidate, expected)
})
})
describe('deprecated utilities', () => {
test('`order-none` → `order-0`', { timeout }, async () => {
let candidate = 'order-none'
let expected = 'order-0'
let input = css`
@import 'tailwindcss';
`
await expectCanonicalization(input, candidate, expected)
})
test('`order-none` → `order-none` with custom implementation', { timeout }, async () => {
let candidate = 'order-none'
let expected = 'order-none'
let input = css`
@import 'tailwindcss';
@utility order-none {
order: none;
}
`
await expectCanonicalization(input, candidate, expected)
})
test('`break-words` → `wrap-break-word`', { timeout }, async () => {
let candidate = 'break-words'
let expected = 'wrap-break-word'
let input = css`
@import 'tailwindcss';
`
await expectCanonicalization(input, candidate, expected)
})
test('`[overflow-wrap:break-word]` → `wrap-break-word`', { timeout }, async () => {
let candidate = '[overflow-wrap:break-word]'
let expected = 'wrap-break-word'
let input = css`
@import 'tailwindcss';
`
await expectCanonicalization(input, candidate, expected)
})
test('`break-words` → `break-words` with custom implementation', { timeout }, async () => {
let candidate = 'break-words'
let expected = 'break-words'
let input = css`
@import 'tailwindcss';
@utility break-words {
break: words;
}
`
await expectCanonicalization(input, candidate, expected)
})
})
describe('arbitrary variants', () => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
`
test.each([
['[&:focus]:flex', 'focus:flex'],
['[@media(scripting:_none)]:flex', 'noscript:flex'],
['[@media(scripting:none)]:flex', 'noscript:flex'],
['[@media(scripting:_none)]:flex', 'noscript:flex'],
['[@media_(scripting:_none)]:flex', 'noscript:flex'],
['has-[&:focus]:flex', 'has-focus:flex'],
['not-[&:focus]:flex', 'not-focus:flex'],
['group-[&:focus]:flex', 'group-focus:flex'],
['peer-[&:focus]:flex', 'peer-focus:flex'],
['in-[&:focus]:flex', 'in-focus:flex'],
])(testName, { timeout }, async (candidate, expected) => {
await expectCanonicalization(input, candidate, expected)
})
test('unsafe migrations keep the candidate as-is', { timeout }, async () => {
let candidate = '[&:hover]:flex'
let expected = '[&:hover]:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
`
await expectCanonicalization(input, candidate, expected)
})
test('make unsafe migration safe (1)', { timeout }, async () => {
let candidate = '[&:hover]:flex'
let expected = 'hover:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant hover (&:hover);
`
await expectCanonicalization(input, candidate, expected)
})
test('make unsafe migration safe (2)', { timeout }, async () => {
let candidate = '[&:hover]:flex'
let expected = 'hover:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant hover {
&:hover {
@slot;
}
}
`
await expectCanonicalization(input, candidate, expected)
})
test('custom selector-based variants', { timeout }, async () => {
let candidate = '[&.macos]:flex'
let expected = 'is-macos:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant is-macos (&.macos);
`
await expectCanonicalization(input, candidate, expected)
})
test('custom @media-based variants', { timeout }, async () => {
let candidate = '[@media(prefers-reduced-transparency:reduce)]:flex'
let expected = 'transparency-safe:flex'
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
}
@variant transparency-safe {
@media (prefers-reduced-transparency: reduce) {
@slot;
}
}
`
await expectCanonicalization(input, candidate, expected)
})
})
describe('drop unnecessary data types', () => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--color-red-500: red;
}
`
test.each([
['bg-[color:#008cc]', 'bg-[#008cc]'],
['bg-(color:--my-value)', 'bg-(--my-value)'],
['bg-(color:--color-red-500)', 'bg-red-500'],
])(testName, { timeout }, async (candidate, expected) => {
await expectCanonicalization(input, candidate, expected)
})
})
describe('arbitrary value to bare value', () => {
test.each([
['aspect-[12/34]', 'aspect-12/34'],
['aspect-[1.2/34]', 'aspect-[1.2/34]'],
['col-start-[7]', 'col-start-7'],
['flex-[2]', 'flex-2'],
['grid-cols-[subgrid]', 'grid-cols-subgrid'],
['grid-rows-[subgrid]', 'grid-rows-subgrid'],
['m-[0]', 'm-0'],
['m-[0px]', 'm-0'],
['m-[0rem]', 'm-0'],
['-m-[0]', 'm-0'],
['-m-[0px]', 'm-0'],
['-m-[0rem]', 'm-0'],
['m-[-0]', 'm-0'],
['m-[-0px]', 'm-0'],
['m-[-0rem]', 'm-0'],
['-m-[-0]', 'm-0'],
['-m-[-0px]', 'm-0'],
['-m-[-0rem]', 'm-0'],
['[margin:0]', 'm-0'],
['[margin:-0]', 'm-0'],
['[margin:0px]', 'm-0'],
['[margin:0%]', 'm-[0%]'],
['font-stretch-[50%]', 'font-stretch-50%'],
['font-stretch-[50.5%]', 'font-stretch-[50.5%]'],
['font-stretch-[201%]', 'font-stretch-[201%]'],
['font-stretch-[49%]', 'font-stretch-[49%]'],
['font-stretch-[1/2]', 'font-stretch-[1/2]'],
['from-[28%]', 'from-28%'],
['via-[28%]', 'via-28%'],
['to-[28%]', 'to-28%'],
['from-[28.5%]', 'from-[28.5%]'],
['via-[28.5%]', 'via-[28.5%]'],
['to-[28.5%]', 'to-[28.5%]'],
['text-[1/2]', 'text-[1/2]'],
['leading-[123]', 'leading-[123]'],
['data-[selected]:flex', 'data-selected:flex'],
['data-[foo=bar]:flex', 'data-[foo=bar]:flex'],
['supports-[gap]:flex', 'supports-gap:flex'],
['supports-[display:grid]:flex', 'supports-[display:grid]:flex'],
['group-data-[selected]:flex', 'group-data-selected:flex'],
['group-data-[foo=bar]:flex', 'group-data-[foo=bar]:flex'],
['group-has-data-[selected]:flex', 'group-has-data-selected:flex'],
['aria-[selected]:flex', 'aria-[selected]:flex'],
['aria-[selected="true"]:flex', 'aria-selected:flex'],
['aria-[selected*="true"]:flex', 'aria-[selected*="true"]:flex'],
['group-aria-[selected]:flex', 'group-aria-[selected]:flex'],
['group-aria-[selected="true"]:flex', 'group-aria-selected:flex'],
['group-has-aria-[selected]:flex', 'group-has-aria-[selected]:flex'],
['max-lg:hover:data-[selected]:flex', 'max-lg:hover:data-selected:flex'],
[
'data-[selected]:aria-[selected="true"]:aspect-[12/34]',
'data-selected:aria-selected:aspect-12/34',
],
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCanonicalization(input, candidate, expected)
})
})
describe('modernize arbitrary variants', () => {
test.each([
['[[data-visible]]:flex', 'data-visible:flex'],
['[&[data-visible]]:flex', 'data-visible:flex'],
['[[data-visible]&]:flex', 'data-visible:flex'],
['[&>[data-visible]]:flex', '*:data-visible:flex'],
['[&_>_[data-visible]]:flex', '*:data-visible:flex'],
['[&>*]:flex', '*:flex'],
['[&_>_*]:flex', '*:flex'],
['[&_>_[foo]]:flex', '*:[[foo]]:flex'],
['[&_[foo]]:flex', '**:[[foo]]:flex'],
['[&_>_[foo=bar]]:flex', '*:[[foo=bar]]:flex'],
['[&_[foo=bar]]:flex', '**:[[foo=bar]]:flex'],
['[&_[data-visible]]:flex', '**:data-visible:flex'],
['[&_*]:flex', '**:flex'],
['[&:first-child]:flex', 'first:flex'],
['[&:not(:first-child)]:flex', 'not-first:flex'],
['[&_:first-child]:flex', '**:first:flex'],
['[&_>_:first-child]:flex', '*:first:flex'],
['[&_:--custom]:flex', '**:[:--custom]:flex'],
['[&_>_:--custom]:flex', '*:[:--custom]:flex'],
['[p_&]:flex', 'in-[p]:flex'],
['[.foo_&]:flex', 'in-[.foo]:flex'],
['[[data-visible]_&]:flex', 'in-data-visible:flex'],
['[[data-foo][data-bar]_&]:flex', '[[data-foo][data-bar]_&]:flex'],
['[figure>&]:my-0', '[figure>&]:my-0'],
['[&:nth-child(2)]:flex', 'nth-2:flex'],
['[&:not(:nth-child(2))]:flex', 'not-nth-2:flex'],
['[&:nth-child(-n+3)]:flex', 'nth-[-n+3]:flex'],
['[&:not(:nth-child(-n+3))]:flex', 'not-nth-[-n+3]:flex'],
['[&:nth-child(-n_+_3)]:flex', 'nth-[-n+3]:flex'],
['[&:not(:nth-child(-n_+_3))]:flex', 'not-nth-[-n+3]:flex'],
['[&:nth-last-child(2)]:flex', 'nth-last-2:flex'],
['[&:not(:nth-last-child(2))]:flex', 'not-nth-last-2:flex'],
['[&:nth-last-child(-n+3)]:flex', 'nth-last-[-n+3]:flex'],
['[&:not(:nth-last-child(-n+3))]:flex', 'not-nth-last-[-n+3]:flex'],
['[&:nth-last-child(-n_+_3)]:flex', 'nth-last-[-n+3]:flex'],
['[&:not(:nth-last-child(-n_+_3))]:flex', 'not-nth-last-[-n+3]:flex'],
['[&:nth-child(odd)]:flex', 'odd:flex'],
['[&:not(:nth-child(odd))]:flex', 'even:flex'],
['[&:nth-child(even)]:flex', 'even:flex'],
['[&:not(:nth-child(even))]:flex', 'odd:flex'],
['[[data-visible][data-dark]]:flex', '[[data-visible][data-dark]]:flex'],
['[:where([data-visible])]:flex', '[:where([data-visible])]:flex'],
['[[data-url*="example"]]:flex', 'data-[url*="example"]:flex'],
['[[data-url$=".com"_i]]:flex', 'data-[url$=".com"_i]:flex'],
['[[data-url$=.com_i]]:flex', 'data-[url$=.com_i]:flex'],
['[&:is([data-visible])]:flex', 'data-visible:flex'],
['[@media(pointer:fine)]:flex', 'pointer-fine:flex'],
['[@media_(pointer_:_fine)]:flex', 'pointer-fine:flex'],
['[@media_not_(pointer_:_fine)]:flex', 'not-pointer-fine:flex'],
['[@media_print]:flex', 'print:flex'],
['[@media_not_print]:flex', 'not-print:flex'],
['[@media_not_(prefers-color-scheme:dark)]:flex', 'not-dark:flex'],
[
'[@media_not_(prefers-color-scheme:unknown)]:flex',
'not-[@media_(prefers-color-scheme:unknown)]:flex',
],
['has-[[data-visible]]:flex', 'has-data-visible:flex'],
['has-[&:is([data-visible])]:flex', 'has-data-visible:flex'],
['has-[&>[data-visible]]:flex', 'has-[&>[data-visible]]:flex'],
['has-[[data-slot=description]]:flex', 'has-data-[slot=description]:flex'],
['has-[&:is([data-slot=description])]:flex', 'has-data-[slot=description]:flex'],
['has-[[aria-visible="true"]]:flex', 'has-aria-visible:flex'],
['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'],
['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'],
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCanonicalization(input, candidate, expected)
})
})
describe('optimize modifier', () => {
let input = css`
@import 'tailwindcss';
@theme {
--*: initial;
--color-red-500: red;
}
`
test.each([
['bg-red-500/25', 'bg-red-500/25'],
['bg-red-500/[25%]', 'bg-red-500/25'],
['bg-[#f00]/[0.16]', 'bg-[#f00]/16'],
['bg-red-500/[100%]', 'bg-red-500'],
['bg-red-500/100', 'bg-red-500'],
['group/name', 'group/name'],
])(testName, { timeout }, async (candidate, expected) => {
await expectCanonicalization(input, candidate, expected)
})
})
describe('combine to shorthand utilities', () => {
test.each([
['mt-1 mr-1 mb-1 ml-1', 'm-1'],
['mt-1 mb-1', 'my-1'],
['mb-1 mt-1', 'my-1'],
['w-4 h-4', 'size-4'],
['hover:w-4 h-4', 'hover:w-4 h-4'],
['[width:_16px_] [height:16px]', 'size-4'],
['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'],
])(testName, { timeout }, async (candidates, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCombinedCanonicalization(input, candidates, expected)
})
})
describe('font-size/line-height to text-{x}/{y}', () => {
test.each([
...Array.from(
cartesian(
['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'],
['[line-height:28px]', 'leading-[28px]', 'leading-7'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/7']),
...Array.from(
cartesian(
['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'],
['[line-height:28px]', 'leading-[28px]', 'leading-7'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/7']),
...Array.from(
cartesian(
['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'],
['[line-height:28.5px]', 'leading-[28.5px]'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/[28.5px]']),
...Array.from(
cartesian(
['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'],
['[line-height:28.5px]', 'leading-[28.5px]'],
),
).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/[28.5px]']),
])(testName, { timeout }, async (candidates, expected) => {
let input = css`
@import 'tailwindcss';
`
await expectCombinedCanonicalization(input, candidates.trim(), expected)
})
})
})
describe('theme to var', () => {
test('extended space scale converts to var or calc', { timeout }, async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@tailwind utilities;
@theme {
--spacing: 0.25rem;
--spacing-2: 2px;
--spacing-miami: 0.875rem;
}
`,
{ base: __dirname },
)
expect(
designSystem.canonicalizeCandidates([
'[--value:theme(spacing.1)]',
'[--value:theme(spacing.2)]',
'[--value:theme(spacing.miami)]',
'[--value:theme(spacing.nyc)]',
]),
).toEqual([
'[--value:--spacing(1)]',
'[--value:var(--spacing-2)]',
'[--value:var(--spacing-miami)]',
'[--value:theme(spacing.nyc)]',
])
})
test('custom space scale converts to var', { timeout }, async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@tailwind utilities;
@theme {
--spacing-*: initial;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
}
`,
{ base: __dirname },
)
expect(
designSystem.canonicalizeCandidates([
'[--value:theme(spacing.1)]',
'[--value:theme(spacing.2)]',
'[--value:theme(spacing.3)]',
]),
).toEqual([
'[--value:var(--spacing-1)]',
'[--value:var(--spacing-2)]',
'[--value:theme(spacing.3)]',
])
})
})
describe('options', () => {
test('normalize `rem` units to `px`', { timeout }, async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@tailwind utilities;
@theme {
--spacing: 0.25rem;
}
`,
{ base: __dirname },
)
expect(designSystem.canonicalizeCandidates(['m-[16px]'])).toEqual(['m-[16px]'])
expect(designSystem.canonicalizeCandidates(['m-[16px]'], { rem: 16 })).toEqual(['m-4'])
expect(designSystem.canonicalizeCandidates(['m-[16px]'], { rem: 64 })).toEqual(['m-1'])
expect(designSystem.canonicalizeCandidates(['m-[16px]'])).toEqual(['m-[16px]'])
})
})