From bf44db3c12bfc9233560ee970a89bf1e2c282625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20Barr=C3=A9?= Date: Sat, 23 Dec 2023 01:12:12 +0100 Subject: [PATCH] Support main dynamic variants --- CHANGELOG.md | 2 + README.md | 16 ++--- src/index.ts | 61 +++++++++++------ src/theme/getBaseTheme.ts | 12 ++++ src/types.d.ts | 2 + src/utils/print.ts | 4 +- src/utils/regex.ts | 8 ++- src/variants.ts | 126 +++++++++++++++++++++++++++-------- tests/generate.test.ts | 10 +++ tests/regex.test.ts | 2 +- tests/snapshots/generate.css | 28 +++++++- 11 files changed, 206 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4e8b4..c10488c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Support main dynamic variants (`min-*`, `max-*`, `(group/peer-)data-*`, `(group/peer-)aria-*`) + ## 0.7.2 - Fix plugin usage in build watch mode diff --git a/README.md b/README.md index 9bb6f96..96ddf94 100644 --- a/README.md +++ b/README.md @@ -151,15 +151,11 @@ export const config: DownwindConfig = { ### Dynamic variants -`supports-*` is supported. +`supports-*`, `min-*`, `max-*`, `(group/peer-)data-*`, `(group/peer-)aria-*` are supported. `max-` is supported when the screens config is a basic `min-width` only. No sorting is done. -Other dynamic variants are not implemented for now. It means `min-*`, `data-*`, `aria-*`, `group-*`, `peer-*` are **not** supported. - -Punctual need usage can be accomplished using arbitrary variants: `[@media(min-width:900px)]:block` - -Variants modifier (ex. `group/sidebar`) are not supported either. The few cases were there are needed can also be covered with arbitrary variants: +`group-*`, `peer-*` and variants modifier (ex. `group/sidebar`) are not supported. The few cases were there are needed can be covered with arbitrary variants: `group-hover/sidebar:opacity-75 group-hover/navitem:bg-black/75` -> `[.sidebar:hover_&]:opacity-75 group-hover:bg-black/75` ### Variants @@ -222,7 +218,7 @@ To avoid parsing errors in WebStorm, double quotes are required. And because [th ### Almost exhaustive list of non-supported features - Container queries, but this will probably be added later -- Some `addVariant` capabilities like generating at-rules. Also something useful to support in the future +- Adding variants via plugins. Also something useful to support in the future - [prefix](https://tailwindcss.com/docs/configuration#prefix), [separator](https://tailwindcss.com/docs/configuration#separator) and [important](https://tailwindcss.com/docs/configuration#important) configuration options - These deprecated utils: `transform`, `transform-cpu`, `decoration-slice` `decoration-clone`, `filter`, `backdrop-filter`, `blur-0` - These deprecated colors: `lightBlue`, `warmGray`, `trueGray`, `coolGray`, `blueGray` @@ -244,7 +240,7 @@ To avoid parsing errors in WebStorm, double quotes are required. And because [th ## How it works -When loading the configuration, three maps are generated: one for all possible variants, one for all static rules and one for all prefix with possible arbitrary values. +When loading the configuration, four maps are generated: one for static variants, one for prefixes of dynamic variants, one for static rules and one for prefixes of arbitrary values. Then an object with few methods is returned: @@ -265,7 +261,9 @@ Then an object with few methods is returned: - `scan` is used to scan some source code. A regex is first use to match candidates and then these candidates are parsed roughly like this: - Search for variants (repeat until not match): - If the token start `[`, looks for next `]:` and add the content as arbitrary variant. If no `]:`, test if it's an arbitrary value (`[color:red]`). - - else search `:` and use the prefix to search in the variant map + - else search `:` + - if the left part contains `-[`, search for the prefix in the dynamic variant map + - otherwise lookup the value in the static variant map - Test if the remaining token is part of the static rules - Search for `-[` - if matchs: diff --git a/src/index.ts b/src/index.ts index ff0ea9b..68e4c6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,11 @@ import { } from "./utils/print.ts"; import { escapeSelector, selectorRE } from "./utils/regex.ts"; import { themeGet } from "./utils/themeGet.ts"; -import { type AtRuleVariant, getVariants, type Variant } from "./variants.ts"; +import { + type AtRuleVariant, + getVariants, + type StaticVariant, +} from "./variants.ts"; export const VERSION = __VERSION__; @@ -40,7 +44,7 @@ const themeRE = /theme\("([^)]+)"\)/g; type Match = { token: string; - variants: Variant[]; + variants: StaticVariant[]; important: boolean; } & ( | { type: "Rule"; ruleEntry: RuleEntry } @@ -76,7 +80,7 @@ export const initDownwindWithConfig = ({ }) => { const config = resolveConfig(userConfig); const defaults = getDefaults(config); - const variantsMap = getVariants(config); + const { staticVariantsMap, dynamicVariantsMap } = getVariants(config); const rules = getRules(config); const { rulesEntries, arbitraryEntries } = getEntries(rules); @@ -88,7 +92,7 @@ export const initDownwindWithConfig = ({ // This init is for the `container` edge case Object.keys(config.theme.screens).map( (screen): MatchesGroup["atRules"][number] => { - const variant = variantsMap.get(screen) as AtRuleVariant; + const variant = staticVariantsMap.get(screen) as AtRuleVariant; return { screenKey: screen, order: variant.order, @@ -183,10 +187,10 @@ export const initDownwindWithConfig = ({ let important = false; let tokenWithoutVariants = token; - const variants: Variant[] = []; + const variants: StaticVariant[] = []; let isArbitraryProperty = false; - const extractVariant = (): Variant | "NO_VARIANT" | undefined => { + const extractVariant = (): StaticVariant | "NO_VARIANT" | undefined => { important = tokenWithoutVariants.startsWith("!"); if (important) tokenWithoutVariants = tokenWithoutVariants.slice(1); if (tokenWithoutVariants.startsWith("[")) { @@ -219,23 +223,38 @@ export const initDownwindWithConfig = ({ return undefined; } else if (important) { return "NO_VARIANT"; // Using ! prefix is not valid for variant - } else if (tokenWithoutVariants.startsWith("supports-[")) { - const index = tokenWithoutVariants.indexOf("]:"); - if (index === -1) return; - const content = tokenWithoutVariants.slice(10, index); - tokenWithoutVariants = tokenWithoutVariants.slice(index + 2); - const check = content.includes(":") ? content : `${content}: var(--tw)`; - return { - type: "atRule", - order: Infinity, - condition: `@supports (${check})`, - }; } const index = tokenWithoutVariants.indexOf(":"); if (index === -1) return "NO_VARIANT"; - const prefix = tokenWithoutVariants.slice(0, index); - tokenWithoutVariants = tokenWithoutVariants.slice(index + 1); - return variantsMap.get(prefix); + const dynamicIndex = tokenWithoutVariants.indexOf("-["); + // -[ can be for arbitrary values + if (dynamicIndex === -1 || dynamicIndex > index) { + const prefix = tokenWithoutVariants.slice(0, index); + tokenWithoutVariants = tokenWithoutVariants.slice(index + 1); + return staticVariantsMap.get(prefix); + } + const endIndex = tokenWithoutVariants.indexOf("]:"); + if (endIndex === -1) return; + const dynamicPrefix = tokenWithoutVariants.slice(0, dynamicIndex); + const dynamicVariant = dynamicVariantsMap.get(dynamicPrefix); + if (!dynamicVariant) return; + const content = tokenWithoutVariants + .slice(dynamicIndex + 2, endIndex) + .replaceAll("_", " "); + tokenWithoutVariants = tokenWithoutVariants.slice(endIndex + 2); + switch (dynamicVariant.type) { + case "dynamicAtRule": + return { + type: "atRule", + order: dynamicVariant.order, + condition: dynamicVariant.get(content), + }; + case "dynamicSelectorRewrite": + return { + type: "selectorRewrite", + selectorRewrite: dynamicVariant.get(content), + }; + } }; let variant = extractVariant(); @@ -471,7 +490,7 @@ export const initDownwindWithConfig = ({ const hasScreen = content.includes("screen("); if (hasScreen) { content = content.replaceAll(screenRE, (substring, value: string) => { - const variant = variantsMap.get(value); + const variant = staticVariantsMap.get(value); if (variant === undefined) { throw new DownwindError( `No variant matching "${value}"`, diff --git a/src/theme/getBaseTheme.ts b/src/theme/getBaseTheme.ts index e574b95..e1b8c32 100644 --- a/src/theme/getBaseTheme.ts +++ b/src/theme/getBaseTheme.ts @@ -85,6 +85,17 @@ export const getBaseTheme = (): DownwindTheme => ({ pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", bounce: "bounce 1s infinite", }, + aria: { + busy: 'busy="true"', + checked: 'checked="true"', + disabled: 'disabled="true"', + expanded: 'expanded="true"', + hidden: 'hidden="true"', + pressed: 'pressed="true"', + readonly: 'readonly="true"', + required: 'required="true"', + selected: 'selected="true"', + }, aspectRatio: { auto: "auto", square: "1 / 1", @@ -751,6 +762,7 @@ export const getBaseTheme = (): DownwindTheme => ({ 2: "2", }, supports: {}, + data: {}, textColor: (theme) => theme("colors"), textDecorationColor: (theme) => theme("colors"), textDecorationThickness: { diff --git a/src/types.d.ts b/src/types.d.ts index 06324e9..e6ae09f 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -161,6 +161,7 @@ type ThemeKey = | "columns" | "spacing" | "animation" + | "aria" | "aspectRatio" | "backdropBlur" | "backdropBrightness" @@ -255,6 +256,7 @@ type ThemeKey = | "stroke" | "strokeWidth" | "supports" + | "data" | "textColor" | "textDecorationColor" | "textDecorationThickness" diff --git a/src/utils/print.ts b/src/utils/print.ts index e1604bb..2386812 100644 --- a/src/utils/print.ts +++ b/src/utils/print.ts @@ -1,6 +1,6 @@ import type { ResolvedConfig } from "../resolveConfig.ts"; import type { Container, CSSEntries, RuleMeta } from "../types.d.ts"; -import type { Variant } from "../variants.ts"; +import type { StaticVariant } from "../variants.ts"; export const printBlock = ( selector: string, @@ -57,7 +57,7 @@ export const arbitraryPropertyMatchToLine = (match: { export const applyVariants = ( selector: string, - variants: Variant[], + variants: StaticVariant[], meta: RuleMeta | undefined, ) => { let hasAtRule = false; diff --git a/src/utils/regex.ts b/src/utils/regex.ts index b6c3467..515e622 100644 --- a/src/utils/regex.ts +++ b/src/utils/regex.ts @@ -8,12 +8,14 @@ export const escapeSelector = (selector: string) => { }; const regularVariant = /[a-z0-9][a-z0-9-]+/; -const dynamicVariant = /[a-z]+-\[[a-z:-]+]/; +// ="'_ for attributes: aria-[labelledby='a_b'] +// : for supports query +const dynamicVariant = /[a-z-]+-\[[a-z0-9="'_:-]+]/; // & to position the selector -// []=" for attributes: [&[type="email"]] +// []="' for attributes: [&[type="email"]] // :>+*~.()_ for css selectors: [&:nth-child(3)] [&_p] [&>*] [.sidebar+&] // @ for media: [@media(min-width:900px)] -const arbitraryVariant = /\[[a-z0-9&[\]=":>+*~.()_@-]+]/; +const arbitraryVariant = /\[[a-z0-9&[\]="':>+*~.()_@-]+]/; const variant = new RegExp( `(?:${regularVariant.source}|${dynamicVariant.source}|${arbitraryVariant.source}):`, ); diff --git a/src/variants.ts b/src/variants.ts index 9055e3f..31aac3d 100644 --- a/src/variants.ts +++ b/src/variants.ts @@ -1,39 +1,52 @@ import type { ResolvedConfig } from "./resolveConfig.ts"; import type { SelectorRewrite } from "./types.d.ts"; -export type VariantsMap = Map; +export type StaticVariant = + | { type: "selectorRewrite"; selectorRewrite: SelectorRewrite } + | AtRuleVariant; export type AtRuleVariant = { type: "atRule"; order: number; condition: string; }; -export type Variant = - | { type: "selectorRewrite"; selectorRewrite: SelectorRewrite } - | AtRuleVariant; + +type DynamicVariant = + | { + type: "dynamicAtRule"; + order: number; + prefix: string; + get: (content: string) => /* condition */ string; + } + | { + type: "dynamicSelectorRewrite"; + prefix: string; + get: (content: string) => SelectorRewrite; + }; // https://github.com/tailwindlabs/tailwindcss/blob/master/src/corePlugins.js export const getVariants = (config: ResolvedConfig) => { - const variantsMap: VariantsMap = new Map(); + const staticVariantsMap = new Map(); + const dynamicVariantsMap = new Map(); let atRuleOrder = 0; const screensEntries = Object.entries(config.theme.screens); for (const [screen, values] of screensEntries) { if (values.min) { if (values.max) { - variantsMap.set(screen, { + staticVariantsMap.set(screen, { type: "atRule", order: atRuleOrder++, condition: `@media (min-width: ${values.min}) and (max-width: ${values.max})`, }); } else { - variantsMap.set(screen, { + staticVariantsMap.set(screen, { type: "atRule", order: atRuleOrder++, condition: `@media (min-width: ${values.min})`, }); } } else { - variantsMap.set(screen, { + staticVariantsMap.set(screen, { type: "atRule", order: atRuleOrder++, condition: `@media (max-width: ${values.max!})`, @@ -43,16 +56,29 @@ export const getVariants = (config: ResolvedConfig) => { if (screensEntries.every((e) => e[1].min && !e[1].max)) { for (const [name, { min }] of screensEntries) { - variantsMap.set(`max-${name}`, { + staticVariantsMap.set(`max-${name}`, { type: "atRule", order: atRuleOrder++, - condition: `@media not all and (max-width: ${min!})`, + condition: `@media not all and (min-width: ${min!})`, }); } } + dynamicVariantsMap.set("min", { + type: "dynamicAtRule", + order: atRuleOrder++, + prefix: "min", + get: (content) => `@media (min-width: ${content})`, + }); + dynamicVariantsMap.set("max", { + type: "dynamicAtRule", + order: atRuleOrder++, + prefix: "max", + get: (content) => `@media (max-width: ${content})`, + }); + // Non-compliant: Only support class dark mode - variantsMap.set("dark", { + staticVariantsMap.set("dark", { type: "selectorRewrite", selectorRewrite: (v) => `.dark ${v}`, }); @@ -69,12 +95,28 @@ export const getVariants = (config: ResolvedConfig) => { "after", // Non-compliant: Don't add content property if not present ]) { const [prefix, suffix] = Array.isArray(value) ? value : [value, value]; - variantsMap.set(prefix, { + staticVariantsMap.set(prefix, { type: "selectorRewrite", selectorRewrite: (sel) => `${sel}::${suffix}`, }); } + const withGroupAndPeer = (prefix: string, suffix: string) => { + staticVariantsMap.set(prefix, { + type: "selectorRewrite", + selectorRewrite: (sel) => `${sel}${suffix}`, + }); + // Non-compliant: Don't support complex stacked variants + staticVariantsMap.set(`group-${prefix}`, { + type: "selectorRewrite", + selectorRewrite: (sel) => `.group${suffix} ${sel}`, + }); + staticVariantsMap.set(`peer-${prefix}`, { + type: "selectorRewrite", + selectorRewrite: (sel) => `.peer${suffix} ~ ${sel}`, + }); + }; + for (const value of [ // Positional ["first", ":first-child"], @@ -121,19 +163,7 @@ export const getVariants = (config: ResolvedConfig) => { ? value : [value, `:${value}`]; - variantsMap.set(prefix, { - type: "selectorRewrite", - selectorRewrite: (sel) => `${sel}${suffix}`, - }); - // Non-compliant: Don't support complex stacked variants - variantsMap.set(`group-${prefix}`, { - type: "selectorRewrite", - selectorRewrite: (sel) => `.group${suffix} ${sel}`, - }); - variantsMap.set(`peer-${prefix}`, { - type: "selectorRewrite", - selectorRewrite: (sel) => `.peer${suffix} ~ ${sel}`, - }); + withGroupAndPeer(prefix, suffix); } for (const [key, media] of [ @@ -145,7 +175,7 @@ export const getVariants = (config: ResolvedConfig) => { ["contrast-more", "(prefers-contrast: more)"], ["contrast-less", "(prefers-contrast: less)"], ]) { - variantsMap.set(key, { + staticVariantsMap.set(key, { type: "atRule", order: atRuleOrder++, condition: `@media ${media}`, @@ -153,12 +183,52 @@ export const getVariants = (config: ResolvedConfig) => { } for (const [key, value] of Object.entries(config.theme.supports)) { - variantsMap.set(`supports-${key}`, { + staticVariantsMap.set(`supports-${key}`, { type: "atRule", condition: `@supports (${value!})`, order: atRuleOrder++, }); } + dynamicVariantsMap.set("supports", { + type: "dynamicAtRule", + order: atRuleOrder++, + prefix: "supports", + get: (content) => { + const check = content.includes(":") ? content : `${content}: var(--tw)`; + return `@supports (${check})`; + }, + }); + + const withDynamicGroupAndPeer = ( + prefix: string, + getSuffix: (content: string) => string, + ) => { + dynamicVariantsMap.set(prefix, { + type: "dynamicSelectorRewrite", + prefix, + get: (content) => (sel) => `${sel}${getSuffix(content)}`, + }); + // Non-compliant: Don't support complex stacked variants + dynamicVariantsMap.set(`group-${prefix}`, { + type: "dynamicSelectorRewrite", + prefix: `group-${prefix}`, + get: (content) => (sel) => `.group${getSuffix(content)} ${sel}`, + }); + dynamicVariantsMap.set(`peer-${prefix}`, { + type: "dynamicSelectorRewrite", + prefix: `peer-${prefix}`, + get: (content) => (sel) => `.peer${getSuffix(content)} ~ ${sel}`, + }); + }; + + for (const [key, value] of Object.entries(config.theme.aria)) { + withGroupAndPeer(`aria-${key}`, `[aria-${value!}]`); + } + withDynamicGroupAndPeer("aria", (content) => `[aria-${content}]`); + for (const [key, value] of Object.entries(config.theme.data)) { + withGroupAndPeer(`data-${key}`, `[data-${value!}]`); + } + withDynamicGroupAndPeer("data", (content) => `[data-${content}]`); - return variantsMap; + return { staticVariantsMap, dynamicVariantsMap }; }; diff --git a/tests/generate.test.ts b/tests/generate.test.ts index 62182c1..1c5f55f 100644 --- a/tests/generate.test.ts +++ b/tests/generate.test.ts @@ -105,10 +105,20 @@ const cases: [name: string, content: string, config?: UserConfig][] = [ ], ["arbitrary-media", "[@media(min-width:900px)]:block"], ["max-screen", "sm:max-md:p-2"], + ["min-* max=*", "min-[900px]:p-4 max-[1200px]:m-4"], ["group-nested-media", "p-1 sm:p-3 sm:print:p-2 m-1 sm:m-3 sm:print:m-2"], ["media-order-stable-1", "portrait:p-1 landscape:p-1"], ["media-order-stable-2", "landscape:p-1 portrait:p-1"], ["supports-*", "supports-[container-type]:grid supports-[display:grid]:grid"], + [ + "aria-*", + "aria-checked:underline aria-[sort=ascending]:underline peer-aria-[labelledby='a_b']:underline", + ], + [ + "data-*", + "data-checked:underline data-[position=top]:underline peer-data-[foo='bar_baz']:underline", + { theme: { data: { checked: 'ui~="checked"' } } }, + ], ["disable-rule", "p-4 m-4", { coreRules: { padding: false } }], [ "disable-opacity", diff --git a/tests/regex.test.ts b/tests/regex.test.ts index 9823266..33d13e2 100644 --- a/tests/regex.test.ts +++ b/tests/regex.test.ts @@ -5,6 +5,6 @@ import { selectorRE } from "../src/utils/regex.ts"; test("regex", () => { assert.equal( selectorRE.toString(), - /(?<=['"`\s}])(?:(?:[a-z0-9][a-z0-9-]+|[a-z]+-\[[a-z:-]+]|\[[a-z0-9&[\]=":>+*~.()_@-]+]):)*!?(?:-?(?:[a-z][a-z0-9-]*[a-z0-9%]|[a-z][a-z-]*-\[[a-z0-9#._,'%()+*/-]+])(?:\/(?:[a-z0-9]+|\[[a-z0-9.%()+*/-]+]))?|\[[a-z][a-z-]+:[a-z0-9#._,'%()+*/-]+])(?=['"`\s:$])/g.toString(), + /(?<=['"`\s}])(?:(?:[a-z0-9][a-z0-9-]+|[a-z-]+-\[[a-z0-9="'_:-]+]|\[[a-z0-9&[\]="':>+*~.()_@-]+]):)*!?(?:-?(?:[a-z][a-z0-9-]*[a-z0-9%]|[a-z][a-z-]*-\[[a-z0-9#._,'%()+*/-]+])(?:\/(?:[a-z0-9]+|\[[a-z0-9.%()+*/-]+]))?|\[[a-z][a-z-]+:[a-z0-9#._,'%()+*/-]+])(?=['"`\s:$])/g.toString(), ); }); diff --git a/tests/snapshots/generate.css b/tests/snapshots/generate.css index 065793c..074371d 100644 --- a/tests/snapshots/generate.css +++ b/tests/snapshots/generate.css @@ -414,13 +414,25 @@ html:has(.\[html\:has\(\&\)\]\:bg-blue-500) { /* max-screen: sm:max-md:p-2 */ @media (min-width: 640px) { - @media not all and (max-width: 768px) { + @media not all and (min-width: 768px) { .sm\:max-md\:p-2 { padding: 0.5rem; } } } +/* min-* max=*: min-[900px]:p-4 max-[1200px]:m-4 */ +@media (min-width: 900px) { + .min-\[900px\]\:p-4 { + padding: 1rem; + } +} +@media (max-width: 1200px) { + .max-\[1200px\]\:m-4 { + margin: 1rem; + } +} + /* group-nested-media: p-1 sm:p-3 sm:print:p-2 m-1 sm:m-3 sm:print:m-2 */ .m-1 { margin: 0.25rem; @@ -481,6 +493,20 @@ html:has(.\[html\:has\(\&\)\]\:bg-blue-500) { } } +/* aria-*: aria-checked:underline aria-[sort=ascending]:underline peer-aria-[labelledby='a_b']:underline */ +.aria-\[sort\=ascending\]\:underline[aria-sort=ascending], +.aria-checked\:underline[aria-checked="true"], +.peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=\'a_b\'\]\:underline { + text-decoration-line: underline; +} + +/* data-*: data-checked:underline data-[position=top]:underline peer-data-[foo='bar_baz']:underline */ +.data-\[position\=top\]\:underline[data-position=top], +.data-checked\:underline[data-ui~="checked"], +.peer[data-foo='bar baz'] ~ .peer-data-\[foo\=\'bar_baz\'\]\:underline { + text-decoration-line: underline; +} + /* disable-rule: p-4 m-4 */ .m-4 { margin: 1rem;