feat(compiler-sfc): <style vars> CSS variable injection

This commit is contained in:
Evan You
2020-07-10 16:30:58 -04:00
parent 6647e34ce7
commit bd5c3b96be
8 changed files with 280 additions and 19 deletions

View File

@@ -18,6 +18,7 @@ import {
} from '@babel/types'
import { walk } from 'estree-walker'
import { RawSourceMap } from 'source-map'
import { genCssVarsCode, injectCssVarsCalls } from './genCssVars'
export interface SFCScriptCompileOptions {
/**
@@ -49,13 +50,26 @@ export function compileScript(
)
}
const { script, scriptSetup, source, filename } = sfc
const { script, scriptSetup, styles, source, filename } = sfc
const hasCssVars = styles.some(s => typeof s.attrs.vars === 'string')
const isTS =
(script && script.lang === 'ts') ||
(scriptSetup && scriptSetup.lang === 'ts')
const plugins: ParserPlugin[] = [
...(options.babelParserPlugins || []),
...babelParserDefautPlugins,
...(isTS ? (['typescript'] as const) : [])
]
if (!scriptSetup) {
if (!script) {
throw new Error(`SFC contains no <script> tags.`)
}
return {
...script,
content: hasCssVars ? injectCssVarsCalls(sfc, plugins) : script.content,
bindings: analyzeScriptBindings(script)
}
}
@@ -95,13 +109,6 @@ export function compileScript(
const scriptStartOffset = script && script.loc.start.offset
const scriptEndOffset = script && script.loc.end.offset
const isTS = scriptSetup.lang === 'ts'
const plugins: ParserPlugin[] = [
...(options.babelParserPlugins || []),
...babelParserDefautPlugins,
...(isTS ? (['typescript'] as const) : [])
]
// 1. process normal <script> first if it exists
if (script) {
// import dedupe between <script> and <script setup>
@@ -496,7 +503,7 @@ export function compileScript(
// 6. wrap setup code with function.
// export the content of <script setup> as a named export, `setup`.
// this allows `import { setup } from '*.vue'` for testing purposes.
s.appendLeft(startOffset, `\nexport function setup(${args}) {\n`)
s.prependLeft(startOffset, `\nexport function setup(${args}) {\n`)
// generate return statement
let returned = `{ ${Object.keys(setupExports).join(', ')} }`
@@ -511,6 +518,20 @@ export function compileScript(
returned = `Object.assign(\n ${returned}\n)`
}
// inject `useCSSVars` calls
if (hasCssVars) {
s.prepend(`import { useCSSVars as __useCSSVars__ } from 'vue'\n`)
for (const style of styles) {
const vars = style.attrs.vars
if (typeof vars === 'string') {
s.prependRight(
endOffset,
`\n${genCssVarsCode(vars, !!style.scoped, setupExports)}`
)
}
}
}
s.appendRight(endOffset, `\nreturn ${returned}\n}\n\n`)
// 7. finalize default export

View File

@@ -0,0 +1,76 @@
import {
processExpression,
createTransformContext,
createSimpleExpression,
createRoot,
NodeTypes,
SimpleExpressionNode
} from '@vue/compiler-dom'
import { SFCDescriptor } from './parse'
import { rewriteDefault } from './rewriteDefault'
import { ParserPlugin } from '@babel/parser'
export function genCssVarsCode(
varsExp: string,
scoped: boolean,
knownBindings?: Record<string, boolean>
) {
const exp = createSimpleExpression(varsExp, false)
const context = createTransformContext(createRoot([]), {
prefixIdentifiers: true
})
if (knownBindings) {
// when compiling <script setup> we already know what bindings are exposed
// so we can avoid prefixing them from the ctx.
for (const key in knownBindings) {
context.identifiers[key] = 1
}
}
const transformed = processExpression(exp, context)
const transformedString =
transformed.type === NodeTypes.SIMPLE_EXPRESSION
? transformed.content
: transformed.children
.map(c => {
return typeof c === 'string'
? c
: (c as SimpleExpressionNode).content
})
.join('')
return `__useCSSVars__(_ctx => (${transformedString})${
scoped ? `, true` : ``
})`
}
// <script setup> already gets the calls injected as part of the transform
// this is only for single normal <script>
export function injectCssVarsCalls(
sfc: SFCDescriptor,
parserPlugins: ParserPlugin[]
): string {
const script = rewriteDefault(
sfc.script!.content,
`__default__`,
parserPlugins
)
let calls = ``
for (const style of sfc.styles) {
const vars = style.attrs.vars
if (typeof vars === 'string') {
calls += genCssVarsCode(vars, !!style.scoped) + '\n'
}
}
return (
script +
`\nimport { useCSSVars as __useCSSVars__ } from 'vue'\n` +
`const __injectCSSVars__ = () => {\n${calls}}\n` +
`const __setup__ = __default__.setup\n` +
`__default__.setup = __setup__\n` +
` ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }\n` +
` : __injectCSSVars__\n` +
`export default __default__`
)
}

View File

@@ -3,6 +3,7 @@ export { parse } from './parse'
export { compileTemplate } from './compileTemplate'
export { compileStyle, compileStyleAsync } from './compileStyle'
export { compileScript, analyzeScriptBindings } from './compileScript'
export { rewriteDefault } from './rewriteDefault'
// Types
export {

View File

@@ -0,0 +1,36 @@
import { parse, ParserPlugin } from '@babel/parser'
import MagicString from 'magic-string'
const defaultExportRE = /((?:^|\n|;)\s*)export default/
/**
* Utility for rewriting `export default` in a script block into a varaible
* declaration so that we can inject things into it
*/
export function rewriteDefault(
input: string,
as: string,
parserPlugins?: ParserPlugin[]
): string {
if (!defaultExportRE.test(input)) {
return input + `\nconst ${as} = {}`
}
const replaced = input.replace(defaultExportRE, `$1const ${as} =`)
if (!defaultExportRE.test(replaced)) {
return replaced
}
// if the script somehow still contains `default export`, it probably has
// multi-line comments or template strings. fallback to a full parse.
const s = new MagicString(input)
const ast = parse(input, {
plugins: parserPlugins
}).program.body
ast.forEach(node => {
if (node.type === 'ExportDefaultDeclaration') {
s.overwrite(node.start!, node.declaration.start!, `const ${as} = `)
}
})
return s.toString()
}

View File

@@ -1,6 +1,10 @@
import postcss, { Root } from 'postcss'
import selectorParser, { Node, Selector } from 'postcss-selector-parser'
const animationNameRE = /^(-\w+-)?animation-name$/
const animationRE = /^(-\w+-)?animation$/
const cssVarRE = /\bvar\(--(global:)?([^)]+)\)/g
export default postcss.plugin('vue-scoped', (options: any) => (root: Root) => {
const id: string = options
const keyframes = Object.create(null)
@@ -129,21 +133,22 @@ export default postcss.plugin('vue-scoped', (options: any) => (root: Root) => {
}).processSync(node.selector)
})
// If keyframes are found in this <style>, find and rewrite animation names
// in declarations.
// Caveat: this only works for keyframes and animation rules in the same
// <style> element.
if (Object.keys(keyframes).length) {
root.walkDecls(decl => {
const hasKeyframes = Object.keys(keyframes).length
root.walkDecls(decl => {
// If keyframes are found in this <style>, find and rewrite animation names
// in declarations.
// Caveat: this only works for keyframes and animation rules in the same
// <style> element.
if (hasKeyframes) {
// individual animation-name declaration
if (/^(-\w+-)?animation-name$/.test(decl.prop)) {
if (animationNameRE.test(decl.prop)) {
decl.value = decl.value
.split(',')
.map(v => keyframes[v.trim()] || v.trim())
.join(',')
}
// shorthand
if (/^(-\w+-)?animation$/.test(decl.prop)) {
if (animationRE.test(decl.prop)) {
decl.value = decl.value
.split(',')
.map(v => {
@@ -158,8 +163,15 @@ export default postcss.plugin('vue-scoped', (options: any) => (root: Root) => {
})
.join(',')
}
})
}
}
// rewrite CSS variables
if (cssVarRE.test(decl.value)) {
decl.value = decl.value.replace(cssVarRE, (_, $1, $2) => {
return $1 ? `var(--${$2})` : `var(--${id}-${$2})`
})
}
})
})
function isSpaceCombinator(node: Node) {