diff --git a/packages/compiler-sfc/__tests__/stylePluginScoped.spec.ts b/packages/compiler-sfc/__tests__/stylePluginScoped.spec.ts new file mode 100644 index 00000000..a618ef61 --- /dev/null +++ b/packages/compiler-sfc/__tests__/stylePluginScoped.spec.ts @@ -0,0 +1,192 @@ +import { compileStyle } from '../src/compileStyle' +import { mockWarn } from '@vue/runtime-test' + +function compile(source: string): string { + const res = compileStyle({ + source, + filename: 'test.css', + id: 'test' + }) + if (res.errors.length) { + res.errors.forEach(err => { + console.error(err) + }) + expect(res.errors.length).toBe(0) + } + return res.code +} + +describe('SFC scoped CSS', () => { + mockWarn() + + test('simple selectors', () => { + expect(compile(`h1 { color: red; }`)).toMatch(`h1[test] { color: red;`) + expect(compile(`.foo { color: red; }`)).toMatch(`.foo[test] { color: red;`) + }) + + test('descendent selector', () => { + expect(compile(`h1 .foo { color: red; }`)).toMatch( + `h1 .foo[test] { color: red;` + ) + }) + + test('multiple selectors', () => { + expect(compile(`h1 .foo, .bar, .baz { color: red; }`)).toMatch( + `h1 .foo[test], .bar[test], .baz[test] { color: red;` + ) + }) + + test('pseudo class', () => { + expect(compile(`.foo:after { color: red; }`)).toMatch( + `.foo[test]:after { color: red;` + ) + }) + + test('pseudo element', () => { + expect(compile(`::selection { display: none; }`)).toMatch( + '[test]::selection {' + ) + }) + + test('spaces before pseudo element', () => { + const code = compile(`.abc, ::selection { color: red; }`) + expect(code).toMatch('.abc[test],') + expect(code).toMatch('[test]::selection {') + }) + + test('::v-deep', () => { + expect(compile(`::v-deep(.foo) { color: red; }`)).toMatch( + `[test] .foo { color: red;` + ) + expect(compile(`::v-deep(.foo .bar) { color: red; }`)).toMatch( + `[test] .foo .bar { color: red;` + ) + expect(compile(`.baz .qux ::v-deep(.foo .bar) { color: red; }`)).toMatch( + `.baz .qux[test] .foo .bar { color: red;` + ) + }) + + test('::v-slotted', () => { + expect(compile(`::v-slotted(.foo) { color: red; }`)).toMatch( + `.foo[test-s] { color: red;` + ) + expect(compile(`::v-slotted(.foo .bar) { color: red; }`)).toMatch( + `.foo .bar[test-s] { color: red;` + ) + expect(compile(`.baz .qux ::v-slotted(.foo .bar) { color: red; }`)).toMatch( + `.baz .qux[test] .foo .bar[test-s] { color: red;` + ) + }) + + test('::v-global', () => { + expect(compile(`::v-global(.foo) { color: red; }`)).toMatch( + `.foo { color: red;` + ) + expect(compile(`::v-global(.foo .bar) { color: red; }`)).toMatch( + `.foo .bar { color: red;` + ) + // global ignores anything before it + expect(compile(`.baz .qux ::v-global(.foo .bar) { color: red; }`)).toMatch( + `.foo .bar { color: red;` + ) + }) + + test('scoped keyframes', () => { + const style = compile(` +.anim { + animation: color 5s infinite, other 5s; +} +.anim-2 { + animation-name: color; + animation-duration: 5s; +} +.anim-3 { + animation: 5s color infinite, 5s other; +} +.anim-multiple { + animation: color 5s infinite, opacity 2s; +} +.anim-multiple-2 { + animation-name: color, opacity; + animation-duration: 5s, 2s; +} + +@keyframes color { + from { color: red; } + to { color: green; } +} +@-webkit-keyframes color { + from { color: red; } + to { color: green; } +} +@keyframes opacity { + from { opacity: 0; } + to { opacity: 1; } +} +@-webkit-keyframes opacity { + from { opacity: 0; } + to { opacity: 1; } +} + `) + + expect(style).toContain( + `.anim[test] {\n animation: color-test 5s infinite, other 5s;` + ) + expect(style).toContain(`.anim-2[test] {\n animation-name: color-test`) + expect(style).toContain( + `.anim-3[test] {\n animation: 5s color-test infinite, 5s other;` + ) + expect(style).toContain(`@keyframes color-test {`) + expect(style).toContain(`@-webkit-keyframes color-test {`) + + expect(style).toContain( + `.anim-multiple[test] {\n animation: color-test 5s infinite,opacity-test 2s;` + ) + expect(style).toContain( + `.anim-multiple-2[test] {\n animation-name: color-test,opacity-test;` + ) + expect(style).toContain(`@keyframes opacity-test {`) + expect(style).toContain(`@-webkit-keyframes opacity-test {`) + }) + + // vue-loader/#1370 + test('spaces after selector', () => { + const { code } = compileStyle({ + source: `.foo , .bar { color: red; }`, + filename: 'test.css', + id: 'test' + }) + + expect(code).toMatch(`.foo[test], .bar[test] { color: red;`) + }) + + describe('deprecated syntax', () => { + test('::v-deep as combinator', () => { + expect(compile(`::v-deep .foo { color: red; }`)).toMatch( + `[test] .foo { color: red;` + ) + expect(compile(`.bar ::v-deep .foo { color: red; }`)).toMatch( + `.bar[test] .foo { color: red;` + ) + expect( + `::v-deep usage as a combinator has been deprecated.` + ).toHaveBeenWarned() + }) + + test('>>> (deprecated syntax)', () => { + const code = compile(`>>> .foo { color: red; }`) + expect(code).toMatch(`[test] .foo { color: red;`) + expect( + `the >>> and /deep/ combinators have been deprecated.` + ).toHaveBeenWarned() + }) + + test('/deep/ (deprecated syntax)', () => { + const code = compile(`/deep/ .foo { color: red; }`) + expect(code).toMatch(`[test] .foo { color: red;`) + expect( + `the >>> and /deep/ combinators have been deprecated.` + ).toHaveBeenWarned() + }) + }) +}) diff --git a/packages/compiler-sfc/src/stylePluginScoped.ts b/packages/compiler-sfc/src/stylePluginScoped.ts index adb94889..112e7944 100644 --- a/packages/compiler-sfc/src/stylePluginScoped.ts +++ b/packages/compiler-sfc/src/stylePluginScoped.ts @@ -1,12 +1,12 @@ import postcss, { Root } from 'postcss' -import selectorParser from 'postcss-selector-parser' +import selectorParser, { Node, Selector } from 'postcss-selector-parser' -export default postcss.plugin('add-id', (options: any) => (root: Root) => { +export default postcss.plugin('vue-scoped', (options: any) => (root: Root) => { const id: string = options const keyframes = Object.create(null) - root.each(function rewriteSelectors(node: any) { - if (!node.selector) { + root.each(function rewriteSelectors(node) { + if (node.type !== 'rule') { // handle media queries if (node.type === 'atrule') { if (node.name === 'media' || node.name === 'supports') { @@ -19,22 +19,58 @@ export default postcss.plugin('add-id', (options: any) => (root: Root) => { return } - node.selector = selectorParser((selectors: any) => { - selectors.each(function rewriteSelector( - selector: any, - _i: number, - slotted?: boolean - ) { - let node: any = null + node.selector = selectorParser(selectors => { + function rewriteSelector(selector: Selector, slotted?: boolean) { + let node: Node | null = null // find the last child node to insert attribute selector - selector.each((n: any) => { + selector.each(n => { + // DEPRECATED ">>>" and "/deep/" combinator + if ( + n.type === 'combinator' && + (n.value === '>>>' || n.value === '/deep/') + ) { + n.value = ' ' + n.spaces.before = n.spaces.after = '' + console.warn( + `[@vue/compiler-sfc] the >>> and /deep/ combinators have ` + + `been deprecated. Use ::v-deep instead.` + ) + return false + } + if (n.type === 'pseudo') { // deep: inject [id] attribute at the node before the ::v-deep // combinator. - // .foo ::v-deep .bar -> .foo[xxxxxxx] .bar if (n.value === '::v-deep') { - n.value = n.spaces.before = n.spaces.after = '' + if (n.nodes.length) { + // .foo ::v-deep(.bar) -> .foo[xxxxxxx] .bar + // replace the current node with ::v-deep's inner selector + selector.insertAfter(n, n.nodes[0]) + // insert a space combinator before if it doesn't already have one + const prev = selector.at(selector.index(n) - 1) + if (!prev || !isSpaceCombinator(prev)) { + selector.insertAfter( + n, + selectorParser.combinator({ + value: ' ' + }) + ) + } + selector.removeChild(n) + } else { + // DEPRECATED usage + // .foo ::v-deep .bar -> .foo[xxxxxxx] .bar + console.warn( + `[@vue/compiler-sfc] ::v-deep usage as a combinator has ` + + `been deprecated. Use ::v-deep() instead.` + ) + const prev = selector.at(selector.index(n) - 1) + if (prev && isSpaceCombinator(prev)) { + selector.removeChild(prev) + } + selector.removeChild(n) + } return false } @@ -42,9 +78,9 @@ export default postcss.plugin('add-id', (options: any) => (root: Root) => { // instead. // ::v-slotted(.foo) -> .foo[xxxxxxx-s] if (n.value === '::v-slotted') { - rewriteSelector(n.nodes[0], 0, true /* slotted */) - selectors.insertAfter(selector, n.nodes[0]) - selectors.removeChild(selector) + rewriteSelector(n.nodes[0] as Selector, true /* slotted */) + selector.insertAfter(n, n.nodes[0]) + selector.removeChild(n) return false } @@ -63,7 +99,7 @@ export default postcss.plugin('add-id', (options: any) => (root: Root) => { }) if (node) { - node.spaces.after = '' + ;(node as Node).spaces.after = '' } else { // For deep selectors & standalone pseudo selectors, // the attribute selectors are prepended rather than appended. @@ -73,7 +109,9 @@ export default postcss.plugin('add-id', (options: any) => (root: Root) => { const idToAdd = slotted ? id + '-s' : id selector.insertAfter( - node, + // If node is null it means we need to inject [id] at the start + // insertAfter can handle `null` here + node as any, selectorParser.attribute({ attribute: idToAdd, value: idToAdd, @@ -81,7 +119,8 @@ export default postcss.plugin('add-id', (options: any) => (root: Root) => { quoteMark: `"` }) ) - }) + } + selectors.each(selector => rewriteSelector(selector as Selector)) }).processSync(node.selector) }) @@ -117,3 +156,7 @@ export default postcss.plugin('add-id', (options: any) => (root: Root) => { }) } }) + +function isSpaceCombinator(node: Node) { + return node.type === 'combinator' && /^\s+$/.test(node.value) +}