Fix :global() scoping (#1162)
* Fix :global() scoping #1155 * Improve :global() scoping rules further
This commit is contained in:
parent
a79b6db152
commit
ece0953aed
3 changed files with 66 additions and 29 deletions
5
.changeset/late-moons-wave.md
Normal file
5
.changeset/late-moons-wave.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix CSS :global() selector bug
|
|
@ -10,6 +10,7 @@ interface Selector {
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GLOBAL = ':global('; // custom function to prevent scoping
|
||||||
const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']);
|
const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']);
|
||||||
const KEYFRAME_PERCENT = /\d+\.?\d*%/;
|
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');
|
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 */
|
/** 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']);
|
export const NEVER_SCOPED_TAGS = new Set<string>(['base', 'body', 'font', 'frame', 'frameset', 'head', 'html', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'title']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope Rules
|
* 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`)
|
* 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;
|
return selector;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitize & normalize
|
||||||
|
const input = minifySelector(selector);
|
||||||
|
|
||||||
// For everything else, parse & scope
|
// For everything else, parse & scope
|
||||||
const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.'
|
const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.'
|
||||||
const selectors: Selector[] = [];
|
const selectors: Selector[] = [];
|
||||||
let ss = selector; // final output
|
let ss = input; // sanitize
|
||||||
|
|
||||||
// Pass 1: parse selector string; extract top-level selectors
|
// Pass 1: parse selector string; extract top-level selectors
|
||||||
{
|
{
|
||||||
let start = 0;
|
let start = 0;
|
||||||
let lastValue = '';
|
let lastValue = '';
|
||||||
let parensOpen = false;
|
let parenCount = 0;
|
||||||
for (let n = 0; n < ss.length; n++) {
|
for (let n = 0; n < ss.length; n++) {
|
||||||
const isEnd = n === selector.length - 1;
|
const isEnd = n === input.length - 1;
|
||||||
if (selector[n] === '(') parensOpen = true;
|
if (input[n] === '(') parenCount += 1;
|
||||||
if (selector[n] === ')') parensOpen = false;
|
if (input[n] === ')') parenCount -= 1;
|
||||||
if (isEnd || (parensOpen === false && CSS_SEPARATORS.has(selector[n]))) {
|
if (isEnd || (parenCount === 0 && CSS_SEPARATORS.has(input[n]))) {
|
||||||
lastValue = selector.substring(start, isEnd ? undefined : n);
|
lastValue = input.substring(start, isEnd ? undefined : n);
|
||||||
if (!lastValue) continue;
|
if (!lastValue) continue;
|
||||||
selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue });
|
selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue });
|
||||||
start = n + 1;
|
start = n + 1;
|
||||||
|
@ -57,25 +74,29 @@ export function scopeRule(selector: string, className: string) {
|
||||||
|
|
||||||
// Pass 2: starting from end, transform selectors w/ scoped class
|
// Pass 2: starting from end, transform selectors w/ scoped class
|
||||||
for (let i = selectors.length - 1; i >= 0; i--) {
|
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 head = ss.substring(0, start);
|
||||||
const tail = ss.substring(end);
|
const tail = ss.substring(end);
|
||||||
|
|
||||||
// replace '*' with className
|
// leave :global() alone!
|
||||||
if (value === '*') {
|
if (value.includes(GLOBAL)) {
|
||||||
ss = head + c + tail;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// leave :global() alone!
|
// replace '*' with scoped class
|
||||||
if (value.startsWith(':global(')) {
|
if (value.includes('*')) {
|
||||||
ss =
|
ss = head + value.replace(/\*/g, c) + tail;
|
||||||
head +
|
|
||||||
ss
|
|
||||||
.substring(start, end)
|
|
||||||
.replace(/^:global\(/, '')
|
|
||||||
.replace(/\)$/, '') +
|
|
||||||
tail;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,14 +106,19 @@ export function scopeRule(selector: string, className: string) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// scope everything else
|
// scope everything else (place class just before any pseudoclasses)
|
||||||
let newSelector = value;
|
let pseudoclassStart = -1;
|
||||||
const pseudoIndex = newSelector.search(/(?<!\\):/);
|
for (let n = 0; n < value.length; n++) {
|
||||||
if (pseudoIndex > 0) {
|
// note: CSS may allow backslash-escaped colons, which does not count as a pseudoclass
|
||||||
// if there’s a pseudoclass (:focus or ::before)
|
if (value[n] === ':' && value[n - 1] !== '\\') {
|
||||||
ss = head + newSelector.substring(0, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail;
|
pseudoclassStart = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pseudoclassStart !== -1) {
|
||||||
|
ss = head + value.substring(0, pseudoclassStart) + c + value.substring(pseudoclassStart) + tail;
|
||||||
} else {
|
} 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',
|
postcssPlugin: '@astrojs/postcss-scoped-styles',
|
||||||
Rule(rule) {
|
Rule(rule) {
|
||||||
if (!rulesScopedCache.has(rule)) {
|
if (!rulesScopedCache.has(rule)) {
|
||||||
rule.selector = scopeRule(minifySelector(rule.selector), options.className);
|
rule.selector = scopeRule(rule.selector, options.className);
|
||||||
rulesScopedCache.add(rule);
|
rulesScopedCache.add(rule);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -22,7 +22,13 @@ ScopedStyles('Scopes rules correctly', () => {
|
||||||
'.class :global(*)': `.class.${className} *`,
|
'.class :global(*)': `.class.${className} *`,
|
||||||
'.class :global(.nav:not(.is-active))': `.class.${className} .nav:not(.is-active)`, // preserve nested parens
|
'.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 :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
|
'body h1': `body h1.${className}`, // body shouldn‘t be scoped; it‘s not a component
|
||||||
'html,body': `html,body`,
|
'html,body': `html,body`,
|
||||||
from: 'from', // ignore keyframe keywords (below)
|
from: 'from', // ignore keyframe keywords (below)
|
||||||
|
|
Loading…
Add table
Reference in a new issue