From ece0953aed6879b4e7bf2b812b4a20508181b496 Mon Sep 17 00:00:00 2001 From: Drew Powers <1369770+drwpow@users.noreply.github.com> Date: Wed, 18 Aug 2021 20:46:47 -0600 Subject: [PATCH] Fix :global() scoping (#1162) * Fix :global() scoping #1155 * Improve :global() scoping rules further --- .changeset/late-moons-wave.md | 5 ++ .../transform/postcss-scoped-styles/index.ts | 82 ++++++++++++------- .../astro/test/astro-scoped-styles.test.js | 8 +- 3 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 .changeset/late-moons-wave.md diff --git a/.changeset/late-moons-wave.md b/.changeset/late-moons-wave.md new file mode 100644 index 000000000..6d85e89f4 --- /dev/null +++ b/.changeset/late-moons-wave.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix CSS :global() selector bug diff --git a/packages/astro/src/compiler/transform/postcss-scoped-styles/index.ts b/packages/astro/src/compiler/transform/postcss-scoped-styles/index.ts index 82551effa..a5914c556 100644 --- a/packages/astro/src/compiler/transform/postcss-scoped-styles/index.ts +++ b/packages/astro/src/compiler/transform/postcss-scoped-styles/index.ts @@ -10,6 +10,7 @@ interface Selector { value: string; } +const GLOBAL = ':global('; // custom function to prevent scoping const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']); const KEYFRAME_PERCENT = /\d+\.?\d*%/; @@ -18,8 +19,21 @@ function minifySelector(selector: string): string { return selector.replace(/(\r?\n|\s)+/g, ' ').replace(/\s*(,|\+|>|~|\(|\))\s*/g, '$1'); } +/** find matching paren */ +function matchParen(search: string, start: number): number { + if (search[start] !== '(') return -1; + let parenCount = 0; + for (let n = start + 1; n < search.length; n++) { + if (search[n] === ')' && parenCount === 0) return n; + if (search[n] === '(') parenCount += 1; + if (search[n] === ')') parenCount -= 1; + } + return -1; +} + /** HTML tags that should never get scoped classes */ export const NEVER_SCOPED_TAGS = new Set(['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`) @@ -32,22 +46,25 @@ export function scopeRule(selector: string, className: string) { return selector; } + // sanitize & normalize + const input = minifySelector(selector); + // For everything else, parse & scope const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.' const selectors: Selector[] = []; - let ss = selector; // final output + let ss = input; // sanitize // Pass 1: parse selector string; extract top-level selectors { let start = 0; let lastValue = ''; - let parensOpen = false; + let parenCount = 0; 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); + const isEnd = n === input.length - 1; + if (input[n] === '(') parenCount += 1; + if (input[n] === ')') parenCount -= 1; + if (isEnd || (parenCount === 0 && CSS_SEPARATORS.has(input[n]))) { + lastValue = input.substring(start, isEnd ? undefined : n); if (!lastValue) continue; selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue }); start = n + 1; @@ -57,25 +74,29 @@ export function scopeRule(selector: string, className: string) { // 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 { start, end } = selectors[i]; + let value = selectors[i].value; const head = ss.substring(0, start); const tail = ss.substring(end); - // replace '*' with className - if (value === '*') { - ss = head + c + tail; + // leave :global() alone! + if (value.includes(GLOBAL)) { + let withoutGlobal = value; + // :global() may appear multiple times; if so, extract contents of each and combine + while (withoutGlobal.includes(GLOBAL)) { + const globalStart = withoutGlobal.indexOf(GLOBAL); + const globalParenOpen = globalStart + GLOBAL.length - 1; + const globalEnd = matchParen(withoutGlobal, globalParenOpen); + const globalContents = withoutGlobal.substring(globalParenOpen + 1, globalEnd); + withoutGlobal = withoutGlobal.substring(0, globalStart) + globalContents + withoutGlobal.substring(globalEnd + 1); + } + ss = head + withoutGlobal + tail; continue; } - // leave :global() alone! - if (value.startsWith(':global(')) { - ss = - head + - ss - .substring(start, end) - .replace(/^:global\(/, '') - .replace(/\)$/, '') + - tail; + // replace '*' with scoped class + if (value.includes('*')) { + ss = head + value.replace(/\*/g, c) + tail; continue; } @@ -85,14 +106,19 @@ export function scopeRule(selector: string, className: string) { continue; } - // scope everything else - let newSelector = value; - const pseudoIndex = newSelector.search(/(? 0) { - // if there’s a pseudoclass (:focus or ::before) - ss = head + newSelector.substring(0, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail; + // scope everything else (place class just before any pseudoclasses) + let pseudoclassStart = -1; + for (let n = 0; n < value.length; n++) { + // note: CSS may allow backslash-escaped colons, which does not count as a pseudoclass + if (value[n] === ':' && value[n - 1] !== '\\') { + pseudoclassStart = n; + break; + } + } + if (pseudoclassStart !== -1) { + ss = head + value.substring(0, pseudoclassStart) + c + value.substring(pseudoclassStart) + tail; } else { - ss = head + newSelector + c + tail; + ss = head + value + c + tail; } } @@ -106,7 +132,7 @@ export default function astroScopedStyles(options: AstroScopedOptions): Plugin { postcssPlugin: '@astrojs/postcss-scoped-styles', Rule(rule) { if (!rulesScopedCache.has(rule)) { - rule.selector = scopeRule(minifySelector(rule.selector), options.className); + rule.selector = scopeRule(rule.selector, options.className); rulesScopedCache.add(rule); } }, diff --git a/packages/astro/test/astro-scoped-styles.test.js b/packages/astro/test/astro-scoped-styles.test.js index dc8cfba5b..2e04c57cd 100644 --- a/packages/astro/test/astro-scoped-styles.test.js +++ b/packages/astro/test/astro-scoped-styles.test.js @@ -22,7 +22,13 @@ ScopedStyles('Scopes rules correctly', () => { '.class :global(*)': `.class.${className} *`, '.class :global(.nav:not(.is-active))': `.class.${className} .nav:not(.is-active)`, // preserve nested parens '.class :global(ul li)': `.class.${className} ul li`, // allow doubly-scoped selectors - '.class:not(.is-active)': `.class.${className}:not(.is-active)`, // Note: the :not() selector can NOT contain multiple classes, so this is correct; if this causes issues for some people then it‘s worth a discussion + ':global(body:not(.is-light)).is-dark,:global(body:not(.is-dark)).is-light': `body:not(.is-light).is-dark,body:not(.is-dark).is-light`, // :global() can contain parens, and can be chained off of + ':global(.foo):global(.bar)': '.foo.bar', // more :global() shenanigans + '.class:global(.bar)': `.class.bar`, // this is technically a “useless“ :global() but it should still be extracted + '.class:not(.is-active):not(.is-disabled)': `.class.${className}:not(.is-active):not(.is-disabled)`, // Note: the :not() selector can NOT contain multiple classes, so this is correct; if this causes issues for some people then it‘s worth a discussion + ':hover.a:focus': `.${className}:hover.a:focus`, // weird but still valid (yes, it’s valid) + '*:hover': `.${className}:hover`, + ':not(.is-disabled).a': `.${className}:not(.is-disabled).a`, // also valid 'body h1': `body h1.${className}`, // body shouldn‘t be scoped; it‘s not a component 'html,body': `html,body`, from: 'from', // ignore keyframe keywords (below)