Skip to content

Instantly share code, notes, and snippets.

@tommie
Last active September 1, 2023 13:37
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tommie/c0eefee636033ff48cda50f89e3ccefa to your computer and use it in GitHub Desktop.
Save tommie/c0eefee636033ff48cda50f89e3ccefa to your computer and use it in GitHub Desktop.
A postcss plugin for Vue that adds a :local() pseudo-selector
// 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