diff options
Diffstat (limited to 'src/compiler/transform/postcss-scoped-styles/index.ts')
-rw-r--r-- | src/compiler/transform/postcss-scoped-styles/index.ts | 106 |
1 files changed, 106 insertions, 0 deletions
diff --git a/src/compiler/transform/postcss-scoped-styles/index.ts b/src/compiler/transform/postcss-scoped-styles/index.ts new file mode 100644 index 000000000..23350869c --- /dev/null +++ b/src/compiler/transform/postcss-scoped-styles/index.ts @@ -0,0 +1,106 @@ +import { Declaration, Plugin } from 'postcss'; + +interface AstroScopedOptions { + className: string; +} + +interface Selector { + start: number; + end: number; + value: string; +} + +const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']); +const KEYFRAME_PERCENT = /\d+\.?\d*%/; + +/** HTML tags that should never get scoped classes */ +export const NEVER_SCOPED_TAGS = new Set<string>(['base', 'body', 'font', 'frame', 'frameset', 'head', 'html', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'title']); + +/** + * Scope Rules + * Given a selector string (`.btn>span,.nav>span`), add an additional CSS class to every selector (`.btn.myClass>span.myClass,.nav.myClass>span.myClass`) + * @param {string} selector The minified selector string to parse. Cannot contain arbitrary whitespace (other than child selector syntax). + * @param {string} className The CSS class to apply. + */ +export function scopeRule(selector: string, className: string) { + // if this is a keyframe keyword, return original selector + if (selector === 'from' || selector === 'to' || KEYFRAME_PERCENT.test(selector)) { + return selector; + } + + // For everything else, parse & scope + const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.' + const selectors: Selector[] = []; + let ss = selector; // final output + + // Pass 1: parse selector string; extract top-level selectors + { + let start = 0; + let lastValue = ''; + let parensOpen = false; + for (let n = 0; n < ss.length; n++) { + const isEnd = n === selector.length - 1; + if (selector[n] === '(') parensOpen = true; + if (selector[n] === ')') parensOpen = false; + if (isEnd || (parensOpen === false && CSS_SEPARATORS.has(selector[n]))) { + lastValue = selector.substring(start, isEnd ? undefined : n); + if (!lastValue) continue; + selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue }); + start = n + 1; + } + } + } + + // Pass 2: starting from end, transform selectors w/ scoped class + for (let i = selectors.length - 1; i >= 0; i--) { + const { start, end, value } = selectors[i]; + const head = ss.substring(0, start); + const tail = ss.substring(end); + + // replace '*' with className + if (value === '*') { + ss = head + c + tail; + continue; + } + + // leave :global() alone! + if (value.startsWith(':global(')) { + ss = + head + + ss + .substring(start, end) + .replace(/^:global\(/, '') + .replace(/\)$/, '') + + tail; + continue; + } + + // don‘t scope body, title, etc. + if (NEVER_SCOPED_TAGS.has(value)) { + ss = head + value + tail; + continue; + } + + // scope everything else + let newSelector = ss.substring(start, end); + const pseudoIndex = newSelector.indexOf(':'); + if (pseudoIndex > 0) { + // if there‘s a pseudoclass (:focus) + ss = head + newSelector.substring(start, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail; + } else { + ss = head + newSelector + c + tail; + } + } + + return ss; +} + +/** PostCSS Scope plugin */ +export default function astroScopedStyles(options: AstroScopedOptions): Plugin { + return { + postcssPlugin: '@astro/postcss-scoped-styles', + Rule(rule) { + rule.selector = scopeRule(rule.selector, options.className); + }, + }; +} |