From ab4d0409990835d7829b09b903132e6ed8521204 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Wed, 25 May 2022 14:09:17 +0800 Subject: [PATCH] feat(directives): support `theme()` function (#1005) Co-authored-by: Anthony Fu --- packages/transformer-directives/README.md | 24 +- packages/transformer-directives/src/index.ts | 232 ++++++++++++------- test/__snapshots__/cli.test.ts.snap | 6 - test/transformer-directives.test.ts | 58 +++++ 4 files changed, 228 insertions(+), 92 deletions(-) diff --git a/packages/transformer-directives/README.md b/packages/transformer-directives/README.md index be0f69b92f..7ed8cc2e20 100644 --- a/packages/transformer-directives/README.md +++ b/packages/transformer-directives/README.md @@ -2,7 +2,7 @@ -UnoCSS transformer for `@apply` directive +UnoCSS transformer for `@apply` and `theme()` directive ## Install @@ -25,6 +25,8 @@ export default defineConfig({ ## Usage +### `@apply` + ```css .custom-div { @apply text-center my-0 font-medium; @@ -44,7 +46,7 @@ Will be transformed to: > Currently only `@apply` is supported. -### CSS Variable Style +#### CSS Variable Style To be compatible with vanilla CSS, you can use CSS Variables to replace the `@apply` directive. @@ -72,6 +74,24 @@ transformerDirective({ }) ``` +### `theme()` + +Use the `theme()` function to access your theme config values using dot notation. + +```css +.btn-blue { + background-color: theme('colors.blue.500'); +} +``` + +Will be compiled to: + +```css +.btn-blue { + background-color: #3b82f6; +} +``` + ## License MIT License © 2022-PRESENT [hannoeru](https://github.com/hannoeru) diff --git a/packages/transformer-directives/src/index.ts b/packages/transformer-directives/src/index.ts index fdadc6ef5b..b3268ec81d 100644 --- a/packages/transformer-directives/src/index.ts +++ b/packages/transformer-directives/src/index.ts @@ -1,6 +1,6 @@ import { cssIdRE, expandVariantGroup, notNull, regexScopePlaceholder } from '@unocss/core' import type { SourceCodeTransformer, StringifiedUtil, UnoGenerator } from '@unocss/core' -import type { CssNode, List, ListItem, Selector, SelectorList } from 'css-tree' +import type { CssNode, List, ListItem, Rule, Selector, SelectorList } from 'css-tree' import { clone, generate, parse, walk } from 'css-tree' import type MagicString from 'magic-string' @@ -16,6 +16,13 @@ export interface TransformerDirectivesOptions { * @default '--at-' */ varStyle?: false | string + + /** + * Throw an error if utils or themes are not found. + * + * @default true + */ + throwOnMissing?: boolean } export default function transformerDirectives(options: TransformerDirectivesOptions = {}): SourceCodeTransformer { @@ -37,8 +44,15 @@ export async function transformDirectives( originalCode?: string, offset?: number, ) { - const { varStyle = '--at-' } = options - if (!code.original.includes('@apply') && (varStyle === false || !code.original.includes(varStyle))) + const { + varStyle = '--at-', + throwOnMissing = true, + } = options + + const isApply = code.original.includes('@apply') || (varStyle !== false && code.original.includes(varStyle)) + const hasThemeFn = /theme\([^)]*?\)/.test(code.original) + + if (!isApply && !hasThemeFn) return const ast = parse(originalCode || code.original, { @@ -47,99 +61,149 @@ export async function transformDirectives( filename, }) - const calcOffset = (pos: number) => offset ? pos + offset : pos - if (ast.type !== 'StyleSheet') return - const stack: Promise[] = [] + const calcOffset = (pos: number) => offset ? pos + offset : pos - const processNode = async (node: CssNode, _item: ListItem, _list: List) => { - if (node.type !== 'Rule') + const handleApply = async (node: Rule, childNode: CssNode) => { + let body: string | undefined + if (childNode.type === 'Atrule' && childNode.name === 'apply' && childNode.prelude && childNode.prelude.type === 'Raw') { + body = childNode.prelude.value.trim() + } + else if (varStyle !== false && childNode.type === 'Declaration' && childNode.property === `${varStyle}apply` && childNode.value.type === 'Raw') { + body = childNode.value.value.trim() + // remove quotes + if (body.match(/^(['"]).*\1$/)) + body = body.slice(1, -1) + } + + if (!body) return - await Promise.all( - node.block.children.map(async (childNode, _childItem) => { - if (childNode.type === 'Raw') - return transformDirectives(code, uno, options, filename, childNode.value, calcOffset(childNode.loc!.start.offset)) + const classNames = expandVariantGroup(body).split(/\s+/g) + const utils = ( + await Promise.all( + classNames.map(i => uno.parseToken(i, '-')), + )) + .filter(notNull).flat() + .sort((a, b) => a[0] - b[0]) + .sort((a, b) => (a[3] ? uno.parentOrders.get(a[3]) ?? 0 : 0) - (b[3] ? uno.parentOrders.get(b[3]) ?? 0 : 0)) + .reduce((acc, item) => { + const target = acc.find(i => i[1] === item[1] && i[3] === item[3]) + if (target) + target[2] += item[2] + else + // use spread operator to prevent reassign to uno internal cache + acc.push([...item] as Writeable) + return acc + }, [] as Writeable[]) + + if (!utils.length) + return - let body: string | undefined - if (childNode.type === 'Atrule' && childNode.name === 'apply' && childNode.prelude && childNode.prelude.type === 'Raw') { - body = childNode.prelude.value.trim() - } - else if (varStyle !== false && childNode.type === 'Declaration' && childNode.property === `${varStyle}apply` && childNode.value.type === 'Raw') { - body = childNode.value.value.trim() - // remove quotes - if (body.match(/^(['"]).*\1$/)) - body = body.slice(1, -1) + for (const i of utils) { + const [, _selector, body, parent] = i + const selector = _selector?.replace(regexScopePlaceholder, ' ') || _selector + + if (parent || (selector && selector !== '.\\-')) { + let newSelector = generate(node.prelude) + if (selector && selector !== '.\\-') { + const selectorAST = parse(selector, { + context: 'selector', + }) as Selector + + const prelude = clone(node.prelude) as SelectorList + + prelude.children.forEach((child) => { + const parentSelectorAst = clone(selectorAST) as Selector + parentSelectorAst.children.forEach((i) => { + if (i.type === 'ClassSelector' && i.name === '\\-') + Object.assign(i, clone(child)) + }) + Object.assign(child, parentSelectorAst) + }) + newSelector = generate(prelude) } - if (!body) - return - - const classNames = expandVariantGroup(body).split(/\s+/g) - const utils = ( - await Promise.all( - classNames.map(i => uno.parseToken(i, '-')), - )) - .filter(notNull).flat() - .sort((a, b) => a[0] - b[0]) - .sort((a, b) => (a[3] ? uno.parentOrders.get(a[3]) ?? 0 : 0) - (b[3] ? uno.parentOrders.get(b[3]) ?? 0 : 0)) - .reduce((acc, item) => { - const target = acc.find(i => i[1] === item[1] && i[3] === item[3]) - if (target) - target[2] += item[2] - else - // use spread operator to prevent reassign to uno internal cache - acc.push([...item] as Writeable) - return acc - }, [] as Writeable[]) - - if (!utils.length) - return - - for (const i of utils) { - const [, _selector, body, parent] = i - const selector = _selector?.replace(regexScopePlaceholder, ' ') || _selector - - if (parent || (selector && selector !== '.\\-')) { - let newSelector = generate(node.prelude) - if (selector && selector !== '.\\-') { - const selectorAST = parse(selector, { - context: 'selector', - }) as Selector - - const prelude = clone(node.prelude) as SelectorList - - prelude.children.forEach((child) => { - const parentSelectorAst = clone(selectorAST) as Selector - parentSelectorAst.children.forEach((i) => { - if (i.type === 'ClassSelector' && i.name === '\\-') - Object.assign(i, clone(child)) - }) - Object.assign(child, parentSelectorAst) - }) - newSelector = generate(prelude) - } - - let css = `${newSelector}{${body}}` - if (parent) - css = `${parent}{${css}}` - - code.appendLeft(calcOffset(node.loc!.end.offset), css) - } - else { - code.appendRight(calcOffset(childNode.loc!.end.offset), body) - } - } - code.remove( - calcOffset(childNode.loc!.start.offset), - calcOffset(childNode.loc!.end.offset), - ) - }).toArray(), + let css = `${newSelector}{${body}}` + if (parent) + css = `${parent}{${css}}` + + code.appendLeft(calcOffset(node.loc!.end.offset), css) + } + else { + code.appendRight(calcOffset(childNode.loc!.end.offset), body) + } + } + code.remove( + calcOffset(childNode.loc!.start.offset), + calcOffset(childNode.loc!.end.offset), ) } + const handleThemeFn = (node: CssNode) => { + if (node.type === 'Function' && node.name === 'theme' && node.children) { + const children = node.children.toArray().filter(n => n.type === 'String') + + // TODO: to discuss how we handle multiple theme params + // https://github.com/unocss/unocss/pull/1005#issuecomment-1136757201 + if (children.length !== 1) + throw new Error(`theme() expect exact one argument, but got ${children.length}`) + + const matchedThemes = children.map((childNode) => { + if (childNode.type !== 'String') + return null + + const keys = childNode.value.split('.') + + let value: any = uno.config.theme + + keys.every((key) => { + if (!Reflect.has(value, key)) { + value = null + return false + } + value = value[key] + return true + }) + + if (typeof value === 'string') + return value + if (throwOnMissing) + throw new Error(`theme of "${childNode.value}" did not found`) + return null + }) + + if (matchedThemes.length !== children.length) + return + + code.overwrite( + calcOffset(node.loc!.start.offset), + calcOffset(node.loc!.end.offset), + matchedThemes.join(' '), + ) + } + } + + const stack: Promise[] = [] + + const processNode = async (node: CssNode, _item: ListItem, _list: List) => { + if (hasThemeFn) { + handleThemeFn(node) + } + else if (isApply && node.type === 'Rule') { + await Promise.all( + node.block.children.map(async (childNode, _childItem) => { + if (childNode.type === 'Raw') + return transformDirectives(code, uno, options, filename, childNode.value, calcOffset(childNode.loc!.start.offset)) + + await handleApply(node, childNode) + }).toArray(), + ) + } + } + walk(ast, (...args) => stack.push(processNode(...args))) await Promise.all(stack) diff --git a/test/__snapshots__/cli.test.ts.snap b/test/__snapshots__/cli.test.ts.snap index c5506fce2d..dde56f1ae2 100644 --- a/test/__snapshots__/cli.test.ts.snap +++ b/test/__snapshots__/cli.test.ts.snap @@ -5,9 +5,3 @@ exports[`cli > builds uno.css 1`] = ` .max-w-screen-md{max-width:768px;} .p-4{padding:1rem;}" `; - -exports[`cli > supports unocss.config.js 1`] = ` -"/* layer: shortcuts */ -.box{--un-shadow-inset:var(--un-empty,/*!*/ /*!*/);--un-shadow:0 0 #0000;} -.box{margin-left:auto;margin-right:auto;max-width:80rem;border-radius:0.375rem;--un-bg-opacity:1;background-color:rgba(243,244,246,var(--un-bg-opacity));padding:1rem;--un-shadow:var(--un-shadow-inset) 0 1px 2px 0 var(--un-shadow-color, rgba(0,0,0,0.05));box-shadow:var(--un-ring-offset-shadow, 0 0 #0000), var(--un-ring-shadow, 0 0 #0000), var(--un-shadow);}" -`; diff --git a/test/transformer-directives.test.ts b/test/transformer-directives.test.ts index 682d963886..bf7a0facf1 100644 --- a/test/transformer-directives.test.ts +++ b/test/transformer-directives.test.ts @@ -395,4 +395,62 @@ describe('transformer-directives', () => { " `) }) + + describe('theme()', () => { + test('basic', async () => { + const result = await transform( + `.btn { + background-color: theme("colors.blue.500"); + padding: theme("spacing.xs") theme("spacing.sm"); + } + .btn-2 { + height: calc(100vh - theme('spacing.sm')); + }`, + ) + expect(result) + .toMatchInlineSnapshot(` + ".btn { + background-color: #3b82f6; + padding: 0.75rem 0.875rem; + } + .btn-2 { + height: calc(100vh - 0.875rem); + } + " + `) + }) + + test('non-exist', async () => { + expect(async () => await transform( + `.btn { + color: theme("color.none.500"); + }`, + )).rejects + .toMatchInlineSnapshot('[Error: theme of "color.none.500" did not found]') + + expect(async () => await transform( + `.btn { + font-size: theme("size.lg"); + }`, + )).rejects + .toMatchInlineSnapshot('[Error: theme of "size.lg" did not found]') + }) + + test('args', async () => { + expect(async () => await transform( + `.btn { + color: theme(); + }`, + )).rejects + .toMatchInlineSnapshot('[Error: theme() expect exact one argument, but got 0]') + + // TODO: maybe support it in the future + expect(async () => await transform( + `.btn { + color: theme('colors.blue.500', 'colors.blue.400'); + }`, + )).rejects + .toMatchInlineSnapshot('[Error: theme() expect exact one argument, but got 2]') + }) + }) })