Skip to content

Commit

Permalink
feat(directives): support theme() function (#1005)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
  • Loading branch information
Dunqing and antfu committed May 25, 2022
1 parent d3c8b1f commit ab4d040
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 92 deletions.
24 changes: 22 additions & 2 deletions packages/transformer-directives/README.md
Expand Up @@ -2,7 +2,7 @@

<!-- @unocss-ignore -->

UnoCSS transformer for `@apply` directive
UnoCSS transformer for `@apply` and `theme()` directive

## Install

Expand All @@ -25,6 +25,8 @@ export default defineConfig({

## Usage

### `@apply`

```css
.custom-div {
@apply text-center my-0 font-medium;
Expand All @@ -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.

Expand Down Expand Up @@ -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 &copy; 2022-PRESENT [hannoeru](https://github.com/hannoeru)
232 changes: 148 additions & 84 deletions 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'

Expand All @@ -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 {
Expand All @@ -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, {
Expand All @@ -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<void>[] = []
const calcOffset = (pos: number) => offset ? pos + offset : pos

const processNode = async (node: CssNode, _item: ListItem<CssNode>, _list: List<CssNode>) => {
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<StringifiedUtil>)
return acc
}, [] as Writeable<StringifiedUtil>[])

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<StringifiedUtil>)
return acc
}, [] as Writeable<StringifiedUtil>[])

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<void>[] = []

const processNode = async (node: CssNode, _item: ListItem<CssNode>, _list: List<CssNode>) => {
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)
Expand Down
6 changes: 0 additions & 6 deletions test/__snapshots__/cli.test.ts.snap
Expand Up @@ -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);}"
`;
58 changes: 58 additions & 0 deletions test/transformer-directives.test.ts
Expand Up @@ -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]')
})
})
})

0 comments on commit ab4d040

Please sign in to comment.