Skip to content

Commit bd5c3b9

Browse files
committed
feat(compiler-sfc): <style vars> CSS variable injection
1 parent 6647e34 commit bd5c3b9

File tree

8 files changed

+280
-19
lines changed

8 files changed

+280
-19
lines changed

packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap

+55
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,61 @@ export default __define__({
105105
})"
106106
`;
107107
108+
exports[`SFC compile <script setup> CSS vars injection <script> w/ default export 1`] = `
109+
"const __default__ = { setup() {} }
110+
import { useCSSVars as __useCSSVars__ } from 'vue'
111+
const __injectCSSVars__ = () => {
112+
__useCSSVars__(_ctx => ({ color: _ctx.color }))
113+
}
114+
const __setup__ = __default__.setup
115+
__default__.setup = __setup__
116+
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
117+
: __injectCSSVars__
118+
export default __default__"
119+
`;
120+
121+
exports[`SFC compile <script setup> CSS vars injection <script> w/ default export in strings/comments 1`] = `
122+
"
123+
// export default {}
124+
const __default__ = {}
125+
126+
import { useCSSVars as __useCSSVars__ } from 'vue'
127+
const __injectCSSVars__ = () => {
128+
__useCSSVars__(_ctx => ({ color: _ctx.color }))
129+
}
130+
const __setup__ = __default__.setup
131+
__default__.setup = __setup__
132+
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
133+
: __injectCSSVars__
134+
export default __default__"
135+
`;
136+
137+
exports[`SFC compile <script setup> CSS vars injection <script> w/ no default export 1`] = `
138+
"const a = 1
139+
const __default__ = {}
140+
import { useCSSVars as __useCSSVars__ } from 'vue'
141+
const __injectCSSVars__ = () => {
142+
__useCSSVars__(_ctx => ({ color: _ctx.color }))
143+
}
144+
const __setup__ = __default__.setup
145+
__default__.setup = __setup__
146+
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
147+
: __injectCSSVars__
148+
export default __default__"
149+
`;
150+
151+
exports[`SFC compile <script setup> CSS vars injection w/ <script setup> 1`] = `
152+
"import { useCSSVars as __useCSSVars__ } from 'vue'
153+
154+
export function setup() {
155+
const color = 'red'
156+
__useCSSVars__(_ctx => ({ color }))
157+
return { color }
158+
}
159+
160+
export default { setup }"
161+
`;
162+
108163
exports[`SFC compile <script setup> errors should allow export default referencing imported binding 1`] = `
109164
"import { bar } from './bar'
110165

packages/compiler-sfc/__tests__/compileScript.spec.ts

+45
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ describe('SFC compile <script setup>', () => {
4949
)
5050
})
5151

52+
test('async/await detection', () => {
53+
// TODO
54+
})
55+
5256
describe('exports', () => {
5357
test('export const x = ...', () => {
5458
const { content, bindings } = compile(
@@ -288,6 +292,47 @@ describe('SFC compile <script setup>', () => {
288292
})
289293
})
290294

295+
describe('CSS vars injection', () => {
296+
test('<script> w/ no default export', () => {
297+
assertCode(
298+
compile(
299+
`<script>const a = 1</script>\n` +
300+
`<style vars="{ color }">div{ color: var(--color); }</style>`
301+
).content
302+
)
303+
})
304+
305+
test('<script> w/ default export', () => {
306+
assertCode(
307+
compile(
308+
`<script>export default { setup() {} }</script>\n` +
309+
`<style vars="{ color }">div{ color: var(--color); }</style>`
310+
).content
311+
)
312+
})
313+
314+
test('<script> w/ default export in strings/comments', () => {
315+
assertCode(
316+
compile(
317+
`<script>
318+
// export default {}
319+
export default {}
320+
</script>\n` +
321+
`<style vars="{ color }">div{ color: var(--color); }</style>`
322+
).content
323+
)
324+
})
325+
326+
test('w/ <script setup>', () => {
327+
assertCode(
328+
compile(
329+
`<script setup>export const color = 'red'</script>\n` +
330+
`<style vars="{ color }">div{ color: var(--color); }</style>`
331+
).content
332+
)
333+
})
334+
})
335+
291336
describe('errors', () => {
292337
test('<script> and <script setup> must have same lang', () => {
293338
expect(

packages/compiler-sfc/__tests__/compileStyle.spec.ts

+15
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,21 @@ describe('SFC scoped CSS', () => {
237237
).toHaveBeenWarned()
238238
})
239239
})
240+
241+
describe('<style vars>', () => {
242+
test('should rewrite CSS vars in scoped mode', () => {
243+
const code = compileScoped(`.foo {
244+
color: var(--color);
245+
font-size: var(--global:font);
246+
}`)
247+
expect(code).toMatchInlineSnapshot(`
248+
".foo[test] {
249+
color: var(--test-color);
250+
font-size: var(--font);
251+
}"
252+
`)
253+
})
254+
})
240255
})
241256

242257
describe('SFC CSS modules', () => {

packages/compiler-sfc/src/compileScript.ts

+30-9
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@babel/types'
1919
import { walk } from 'estree-walker'
2020
import { RawSourceMap } from 'source-map'
21+
import { genCssVarsCode, injectCssVarsCalls } from './genCssVars'
2122

2223
export interface SFCScriptCompileOptions {
2324
/**
@@ -49,13 +50,26 @@ export function compileScript(
4950
)
5051
}
5152

52-
const { script, scriptSetup, source, filename } = sfc
53+
const { script, scriptSetup, styles, source, filename } = sfc
54+
const hasCssVars = styles.some(s => typeof s.attrs.vars === 'string')
55+
56+
const isTS =
57+
(script && script.lang === 'ts') ||
58+
(scriptSetup && scriptSetup.lang === 'ts')
59+
60+
const plugins: ParserPlugin[] = [
61+
...(options.babelParserPlugins || []),
62+
...babelParserDefautPlugins,
63+
...(isTS ? (['typescript'] as const) : [])
64+
]
65+
5366
if (!scriptSetup) {
5467
if (!script) {
5568
throw new Error(`SFC contains no <script> tags.`)
5669
}
5770
return {
5871
...script,
72+
content: hasCssVars ? injectCssVarsCalls(sfc, plugins) : script.content,
5973
bindings: analyzeScriptBindings(script)
6074
}
6175
}
@@ -95,13 +109,6 @@ export function compileScript(
95109
const scriptStartOffset = script && script.loc.start.offset
96110
const scriptEndOffset = script && script.loc.end.offset
97111

98-
const isTS = scriptSetup.lang === 'ts'
99-
const plugins: ParserPlugin[] = [
100-
...(options.babelParserPlugins || []),
101-
...babelParserDefautPlugins,
102-
...(isTS ? (['typescript'] as const) : [])
103-
]
104-
105112
// 1. process normal <script> first if it exists
106113
if (script) {
107114
// import dedupe between <script> and <script setup>
@@ -496,7 +503,7 @@ export function compileScript(
496503
// 6. wrap setup code with function.
497504
// export the content of <script setup> as a named export, `setup`.
498505
// this allows `import { setup } from '*.vue'` for testing purposes.
499-
s.appendLeft(startOffset, `\nexport function setup(${args}) {\n`)
506+
s.prependLeft(startOffset, `\nexport function setup(${args}) {\n`)
500507

501508
// generate return statement
502509
let returned = `{ ${Object.keys(setupExports).join(', ')} }`
@@ -511,6 +518,20 @@ export function compileScript(
511518
returned = `Object.assign(\n ${returned}\n)`
512519
}
513520

521+
// inject `useCSSVars` calls
522+
if (hasCssVars) {
523+
s.prepend(`import { useCSSVars as __useCSSVars__ } from 'vue'\n`)
524+
for (const style of styles) {
525+
const vars = style.attrs.vars
526+
if (typeof vars === 'string') {
527+
s.prependRight(
528+
endOffset,
529+
`\n${genCssVarsCode(vars, !!style.scoped, setupExports)}`
530+
)
531+
}
532+
}
533+
}
534+
514535
s.appendRight(endOffset, `\nreturn ${returned}\n}\n\n`)
515536

516537
// 7. finalize default export
+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
processExpression,
3+
createTransformContext,
4+
createSimpleExpression,
5+
createRoot,
6+
NodeTypes,
7+
SimpleExpressionNode
8+
} from '@vue/compiler-dom'
9+
import { SFCDescriptor } from './parse'
10+
import { rewriteDefault } from './rewriteDefault'
11+
import { ParserPlugin } from '@babel/parser'
12+
13+
export function genCssVarsCode(
14+
varsExp: string,
15+
scoped: boolean,
16+
knownBindings?: Record<string, boolean>
17+
) {
18+
const exp = createSimpleExpression(varsExp, false)
19+
const context = createTransformContext(createRoot([]), {
20+
prefixIdentifiers: true
21+
})
22+
if (knownBindings) {
23+
// when compiling <script setup> we already know what bindings are exposed
24+
// so we can avoid prefixing them from the ctx.
25+
for (const key in knownBindings) {
26+
context.identifiers[key] = 1
27+
}
28+
}
29+
const transformed = processExpression(exp, context)
30+
const transformedString =
31+
transformed.type === NodeTypes.SIMPLE_EXPRESSION
32+
? transformed.content
33+
: transformed.children
34+
.map(c => {
35+
return typeof c === 'string'
36+
? c
37+
: (c as SimpleExpressionNode).content
38+
})
39+
.join('')
40+
41+
return `__useCSSVars__(_ctx => (${transformedString})${
42+
scoped ? `, true` : ``
43+
})`
44+
}
45+
46+
// <script setup> already gets the calls injected as part of the transform
47+
// this is only for single normal <script>
48+
export function injectCssVarsCalls(
49+
sfc: SFCDescriptor,
50+
parserPlugins: ParserPlugin[]
51+
): string {
52+
const script = rewriteDefault(
53+
sfc.script!.content,
54+
`__default__`,
55+
parserPlugins
56+
)
57+
58+
let calls = ``
59+
for (const style of sfc.styles) {
60+
const vars = style.attrs.vars
61+
if (typeof vars === 'string') {
62+
calls += genCssVarsCode(vars, !!style.scoped) + '\n'
63+
}
64+
}
65+
66+
return (
67+
script +
68+
`\nimport { useCSSVars as __useCSSVars__ } from 'vue'\n` +
69+
`const __injectCSSVars__ = () => {\n${calls}}\n` +
70+
`const __setup__ = __default__.setup\n` +
71+
`__default__.setup = __setup__\n` +
72+
` ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }\n` +
73+
` : __injectCSSVars__\n` +
74+
`export default __default__`
75+
)
76+
}

packages/compiler-sfc/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { parse } from './parse'
33
export { compileTemplate } from './compileTemplate'
44
export { compileStyle, compileStyleAsync } from './compileStyle'
55
export { compileScript, analyzeScriptBindings } from './compileScript'
6+
export { rewriteDefault } from './rewriteDefault'
67

78
// Types
89
export {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { parse, ParserPlugin } from '@babel/parser'
2+
import MagicString from 'magic-string'
3+
4+
const defaultExportRE = /((?:^|\n|;)\s*)export default/
5+
6+
/**
7+
* Utility for rewriting `export default` in a script block into a varaible
8+
* declaration so that we can inject things into it
9+
*/
10+
export function rewriteDefault(
11+
input: string,
12+
as: string,
13+
parserPlugins?: ParserPlugin[]
14+
): string {
15+
if (!defaultExportRE.test(input)) {
16+
return input + `\nconst ${as} = {}`
17+
}
18+
19+
const replaced = input.replace(defaultExportRE, `$1const ${as} =`)
20+
if (!defaultExportRE.test(replaced)) {
21+
return replaced
22+
}
23+
24+
// if the script somehow still contains `default export`, it probably has
25+
// multi-line comments or template strings. fallback to a full parse.
26+
const s = new MagicString(input)
27+
const ast = parse(input, {
28+
plugins: parserPlugins
29+
}).program.body
30+
ast.forEach(node => {
31+
if (node.type === 'ExportDefaultDeclaration') {
32+
s.overwrite(node.start!, node.declaration.start!, `const ${as} = `)
33+
}
34+
})
35+
return s.toString()
36+
}

0 commit comments

Comments
 (0)