postcss-single-spa-scoped
Version:
PostCSS plugin for manipulating the CSS in a single-spa application to best achieve scoped CSS
202 lines (166 loc) • 7.05 kB
JavaScript
const cssesc = require('css.escape');
const path = require('path');
const uuid = require('uuid');
const extractPseudoElement = (str) => {
const pseudoElementsRegex = /^(.*?)(::?(?:after|backdrop|before|cue(?:\([^()]*\))?|file-selector-button|first-letter|first-line|grammar-error|highlight\([^()]+\)|marker|part\([^()]+\)|placeholder|selection|slotted\([^()]+\)|spelling-error|target-text|view-transition(?:-image-pair\([^()]+\)|-group\([^()]+\)|-new\([^()]+\)|-old\([^()]+\))?))$/;
const match = str.match(pseudoElementsRegex);
if (match) {
return {
selector: match[1], // The string before the pseudo-element
pseudoElement: match[2], // The matched pseudo-element
};
}
return null;
}
/**
* @typedef {Object} PluginOpts
* @property {string} [appName]
* @property {boolean} [excludeParcels]
* @property {string[]} [additionalSelectors]
*/
/**
* @type {import('postcss').PluginCreator}
* @param {PluginOpts} opts
*/
module.exports = (opts) => {
opts = opts || {};
let prefix = 'single-spa-application:';
if (opts.appName && opts.appName.length > 0) {
prefix += `${opts.appName}`;
} else {
let pjson;
try {
if (process.env.NODE_ENV === 'test') {
pjson = { name: '@org/app-name' };
} else {
pjson = require(`${process.cwd()}/package.json`);
}
// eslint-disable-next-line no-empty
} catch (e) { } // catch file access errors
if (!pjson || !pjson.name) {
throw new Error("Could not generate prefix. Please provide an appName in the options or ensure your project has a package.json with a name property.");
}
prefix += `${pjson.name}`;
}
prefix = `#${cssesc(prefix)}`;
const keyframeSuffix = '-' + uuid.v4().substring(0, 8);
const processed = Symbol('processed');
return {
postcssPlugin: 'postcss-single-spa-scoped',
AtRule(atRule) {
if (atRule[processed]) return;
if (atRule.name === 'keyframes') {
atRule.params = atRule.params + keyframeSuffix;
atRule[processed] = true;
}
},
Declaration(decl) {
if (decl[processed]) return;
if (decl.prop === 'animation-name') {
decl.value = decl.value + keyframeSuffix;
decl[processed] = true;
}
},
Rule(rule) {
// -1. Check if rule is a keyframes rule (AND DONT PREFIX!)
// This list is subject to grow... I'm unsure of all the use cases.
if (rule.parent && rule.parent.type === 'atrule' && rule.parent.name === 'keyframes') {
rule[processed] = true;
return;
}
// 0. Check if rule has already been processed
if (rule[processed]) return;
// 1. Check if rule has a comment above it to ignore
if (rule.prev() && rule.prev().type === 'comment') {
const comment = rule.prev();
if (comment.text === 'single-spa-prefix-ignore') {
rule[processed] = true;
return;
}
}
// SVELTE HACK
if (rule.source && rule.source.input && rule.source.input.file) {
const filePath = path.parse(rule.source.input.file);
if (filePath.base && filePath.base.includes('.vite-preprocess.')) {
rule[processed] = true;
return;
}
}
if (rule.source && rule.source.input && rule.source.input.file) {
if (/vue&type=style&index=\d+&scoped=[^&]+/.test(rule.source.input.file)) {
rule[processed] = true;
return;
}
}
// do not prefix nested rules
if (rule.parent && rule.parent.type === 'rule') {
rule[processed] = true;
return;
}
const additionalSelectorsProvided = opts.additionalSelectors && opts.additionalSelectors.length > 0;
// sanitize additional selectors
if (additionalSelectorsProvided) {
opts.additionalSelectors = opts.additionalSelectors.filter(s => s !== null && s.length > 0).map(s => s.trim());
}
rule.selector = rule.selectors.reduce((resultSelector, selector, i) => {
let suffix = i === rule.selectors.length - 1 ? '' : ', ';
let additionalSelectors = additionalSelectorsProvided ? ', ' + opts.additionalSelectors.map(s => `${s} ${selector}`).join(', ') : '';
if (selector === ':root') {
if (additionalSelectorsProvided) {
const simpleAdditionalSelectorList =
opts.additionalSelectors.join(', ');
return resultSelector + prefix + ', ' + simpleAdditionalSelectorList + suffix;
} else {
return resultSelector + prefix + suffix;
}
}
if (selector.includes(':root')) {
if (additionalSelectorsProvided) {
const expandedSelector =
Array.from({ length: opts.additionalSelectors.length + 1 }, () => selector)
.map((s, i) => {
const ref = [prefix, ...opts.additionalSelectors];
return s.replace(':root', ref[i]);
})
.join(', ');
return resultSelector + expandedSelector + suffix;
} else {
return resultSelector + selector.replace(':root', prefix) + suffix;
}
}
// 3. Check if selector is already prefixed
if (selector.startsWith(prefix)) {
if (opts.excludeParcels) {
const selectorWithoutPrefix = selector.substring(prefix.length + 1); // +1 for the hierarchy space
const pseudoElement = extractPseudoElement(selectorWithoutPrefix);
if (pseudoElement) {
if (!pseudoElement.selector) pseudoElement.selector = '*';
const excludeParcelsPseudoClass = `:not([id^="single-spa-application\\:parcel"] ${pseudoElement.selector})`;
return resultSelector + prefix + ' ' + pseudoElement.selector + excludeParcelsPseudoClass + pseudoElement.pseudoElement + suffix;
}
const excludeParcelsPseudoClass = `:not([id^="single-spa-application\\:parcel"] ${selectorWithoutPrefix})`;
return resultSelector + selector + excludeParcelsPseudoClass + suffix;
}
return resultSelector + selector + suffix;
}
const pseudoElement = extractPseudoElement(selector);
if (pseudoElement) {
if (opts.excludeParcels) {
if (!pseudoElement.selector) pseudoElement.selector = '*';
const excludeParcelsPseudoClass = `:not([id^="single-spa-application\\:parcel"] ${pseudoElement.selector})`;
return resultSelector + prefix + ' ' + pseudoElement.selector + excludeParcelsPseudoClass + pseudoElement.pseudoElement + additionalSelectors + suffix;
}
}
// 4. Prefix selector
return resultSelector
+ prefix + ' '
+ selector
+ (opts.excludeParcels ? `:not([id^="single-spa-application\\:parcel"] ${selector})` : '')
+ additionalSelectors
+ suffix;
}, '');
rule[processed] = true;
},
}
}
module.exports.postcss = true