Skip to content

Commit

Permalink
feat(compiler-sfc): <style vars> CSS variable injection
Browse files Browse the repository at this point in the history
  • Loading branch information
yyx990803 committed Jul 10, 2020
1 parent 6647e34 commit bd5c3b9
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,61 @@ export default __define__({
})"
`;
exports[`SFC compile <script setup> CSS vars injection <script> w/ default export 1`] = `
"const __default__ = { setup() {} }
import { useCSSVars as __useCSSVars__ } from 'vue'
const __injectCSSVars__ = () => {
__useCSSVars__(_ctx => ({ color: _ctx.color }))
}
const __setup__ = __default__.setup
__default__.setup = __setup__
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
: __injectCSSVars__
export default __default__"
`;
exports[`SFC compile <script setup> CSS vars injection <script> w/ default export in strings/comments 1`] = `
"
// export default {}
const __default__ = {}
import { useCSSVars as __useCSSVars__ } from 'vue'
const __injectCSSVars__ = () => {
__useCSSVars__(_ctx => ({ color: _ctx.color }))
}
const __setup__ = __default__.setup
__default__.setup = __setup__
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
: __injectCSSVars__
export default __default__"
`;
exports[`SFC compile <script setup> CSS vars injection <script> w/ no default export 1`] = `
"const a = 1
const __default__ = {}
import { useCSSVars as __useCSSVars__ } from 'vue'
const __injectCSSVars__ = () => {
__useCSSVars__(_ctx => ({ color: _ctx.color }))
}
const __setup__ = __default__.setup
__default__.setup = __setup__
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
: __injectCSSVars__
export default __default__"
`;
exports[`SFC compile <script setup> CSS vars injection w/ <script setup> 1`] = `
"import { useCSSVars as __useCSSVars__ } from 'vue'
export function setup() {
const color = 'red'
__useCSSVars__(_ctx => ({ color }))
return { color }
}
export default { setup }"
`;
exports[`SFC compile <script setup> errors should allow export default referencing imported binding 1`] = `
"import { bar } from './bar'
Expand Down
45 changes: 45 additions & 0 deletions packages/compiler-sfc/__tests__/compileScript.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ describe('SFC compile <script setup>', () => {
)
})

test('async/await detection', () => {
// TODO
})

describe('exports', () => {
test('export const x = ...', () => {
const { content, bindings } = compile(
Expand Down Expand Up @@ -288,6 +292,47 @@ describe('SFC compile <script setup>', () => {
})
})

describe('CSS vars injection', () => {
test('<script> w/ no default export', () => {
assertCode(
compile(
`<script>const a = 1</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})

test('<script> w/ default export', () => {
assertCode(
compile(
`<script>export default { setup() {} }</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})

test('<script> w/ default export in strings/comments', () => {
assertCode(
compile(
`<script>
// export default {}
export default {}
</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})

test('w/ <script setup>', () => {
assertCode(
compile(
`<script setup>export const color = 'red'</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})
})

describe('errors', () => {
test('<script> and <script setup> must have same lang', () => {
expect(
Expand Down
15 changes: 15 additions & 0 deletions packages/compiler-sfc/__tests__/compileStyle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,21 @@ describe('SFC scoped CSS', () => {
).toHaveBeenWarned()
})
})

describe('<style vars>', () => {
test('should rewrite CSS vars in scoped mode', () => {
const code = compileScoped(`.foo {
color: var(--color);
font-size: var(--global:font);
}`)
expect(code).toMatchInlineSnapshot(`
".foo[test] {
color: var(--test-color);
font-size: var(--font);
}"
`)
})
})
})

describe('SFC CSS modules', () => {
Expand Down
39 changes: 30 additions & 9 deletions packages/compiler-sfc/src/compileScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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(', ')} }`
Expand All @@ -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
Expand Down
76 changes: 76 additions & 0 deletions packages/compiler-sfc/src/genCssVars.ts
Original file line number Diff line number Diff line change
@@ -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__`
)
}
1 change: 1 addition & 0 deletions packages/compiler-sfc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions packages/compiler-sfc/src/rewriteDefault.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Loading

0 comments on commit bd5c3b9

Please sign in to comment.