Last active
September 1, 2023 13:37
-
-
Save tommie/c0eefee636033ff48cda50f89e3ccefa to your computer and use it in GitHub Desktop.
A postcss plugin for Vue that adds a :local() pseudo-selector
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// A postcss plugin that allows partial-global Vue SFC scoped CSS selectors. | |
// | |
// It introduces the :scoped() pseudo-selector. Use this inside Vue's | |
// :global() to once again make something scoped. This is useful | |
// e.g. if you have an attribute/class on the html element to select | |
// theme or locale. | |
// | |
// ## Status | |
// | |
// This works with (at least) Nuxt 3 on Vue 3.3.4. It has not received much | |
// testing. Feedback is welcome. | |
// | |
// ## How it Works | |
// | |
// It is possible to add postcss plugins to Vue's SFC compiler, but | |
// only before the built-in Vue plugins. C.f. `compileStyle.ts`. This | |
// means we get the source CSS, but must output CSS that Vue doesn't | |
// mangle more. We note that `:global()` disables the | |
// `vue-sfc-scoped` processing logic. We also note that the ID Vue | |
// generates for its scoped styling is available as a query parameter | |
// of the source file path. | |
// | |
// Combining this suggests that we must operate on scoped style | |
// elements, and inside the `:global()` pseudo-selector. Therefore, we | |
// add a `:local()` pseudo-selector that simply injects the scope ID | |
// the way Vue normally does, except we do it inside `:global()` where | |
// no further processing will occur. | |
// | |
// ## Ideas for Vue Project | |
// | |
// It would be nice if this hack wasn't needed: that `:global()` could | |
// be followed by something scoped, as previously suggested by others. | |
// | |
// ## See Also | |
// | |
// * https://github.com/vuejs/core/blob/main/packages/compiler-sfc/src/style/pluginScoped.ts (the `script scoped` postcss plugin) | |
// * https://github.com/vuejs/core/blob/2ffe3d5b3e953b63d4743b1e2bc242d50916b545/packages/compiler-sfc/src/compileStyle.ts#L114 | |
// * https://vuejs.org/api/sfc-css-features.html#global-selectors | |
// * https://github.com/vuejs/core/issues/4948 (proposes a similar :local()) | |
// * https://github.com/vuejs/core/issues/5167 (:global() only works alone) | |
// * https://github.com/vuejs/core/issues/6587 (:global() only works alone) | |
// | |
// ## Example | |
// | |
// If using Vite, `vite.config.js` should include: | |
// | |
// css: { | |
// postcss: { | |
// plugins: [ './postcsslocal' ], | |
// }, | |
// } | |
// | |
// If using Nuxt, `nuxt.config.js`, similarly, should include: | |
// | |
// postcss: { | |
// plugins: { | |
// './postcsslocal': {}, | |
// }, | |
// } | |
// | |
// Then, in `style scoped` element: | |
// | |
// :global(html[theme="dark"] :local(.my-image)) { ... } | |
// | |
// ## License | |
// | |
// Copyright (c) 2023 Itergia AB | |
// | |
// Published under the MIT license. | |
// | |
// The plugin is based off of Vue's `pluginScoped`. | |
// | |
import { PluginCreator, Rule } from "postcss"; | |
import selectorParser, { attribute } from "postcss-selector-parser"; | |
const localPlugin: PluginCreator<string> = () => { | |
return { | |
postcssPlugin: "sfc-local", | |
Rule(rule) { | |
const root = rule.root(); | |
const file = root.source?.input.file; | |
if (!file) return; | |
const url = new URL((file.includes("://") ? "" : "file://") + file); | |
const query = new URLSearchParams(url.search); | |
const id = query.get("scoped"); | |
if (!id) return; | |
processRule("data-v-" + id, rule); | |
}, | |
}; | |
}; | |
const processedRules = new WeakSet<Rule>(); | |
function processRule(id: string, rule: Rule) { | |
if (processedRules.has(rule)) { | |
return; | |
} | |
processedRules.add(rule); | |
rule.selector = selectorParser((selectorRoot) => { | |
selectorRoot.each((selector) => { | |
rewriteSelector(id, selector, selectorRoot); | |
}); | |
}).processSync(rule.selector); | |
} | |
function rewriteSelector( | |
id: string, | |
selector: selectorParser.Selector, | |
selectorRoot: selectorParser.Root, | |
inGlobal = false, | |
) { | |
selector.each((n) => { | |
if (n.type !== "pseudo") { | |
return; | |
} | |
switch (n.value) { | |
case ":global": | |
// Recurse into :global, since that's where we do our magic. | |
// Vue assumes :global() is the only selector in a rule. | |
rewriteSelector(id, n.nodes[0], selectorRoot, true); | |
break; | |
case ":local": | |
if (!inGlobal) { | |
throw new Error(":local() must be used inside a :global()"); | |
} | |
// Where we find the local, we inject a [data-v-xx] attribute | |
// selector, just like Vue would do for normal scoped | |
// selectors. | |
{ | |
let last: selectorParser.Selector["nodes"][0] = n; | |
n.nodes[0].each((ss) => { | |
selector.insertAfter(last, ss); | |
last = ss; | |
}); | |
selector.removeChild(n); | |
selector.insertAfter( | |
// If node is null it means we need to inject [id] at the start | |
// insertAfter can handle `null` here | |
last as any, | |
attribute({ | |
attribute: id, | |
value: id, | |
raws: {}, | |
quoteMark: `"`, | |
}), | |
); | |
} | |
return false; | |
} | |
}); | |
} | |
localPlugin.postcss = true; | |
export default localPlugin; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment