Skip to content

Commit

Permalink
stash
Browse files Browse the repository at this point in the history
  • Loading branch information
m-akinc committed Apr 12, 2024
1 parent 1280713 commit 72c6294
Show file tree
Hide file tree
Showing 2 changed files with 86 additions and 38 deletions.
18 changes: 18 additions & 0 deletions src/preview/rewriteStyleSheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,24 @@ describe("rewriteStyleSheet", () => {
expect(selectors).toContain(":host(.pseudo-focus-all) foo:not(:host(.pseudo-hover-all) *, :host(.pseudo-active-all) .bar) .baz")
})

it('supports ":not" inside ":host"', () => {
const sheet = new Sheet(":host(.foo:not(:hover)) .baz:active { color: red }")
rewriteStyleSheet(sheet as any, true)
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain(":host(.foo:not(:hover)) .baz:active")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover)) .baz.pseudo-active")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover-all).pseudo-active-all) .baz")
})

it('supports ":not" inside and outside of ":host"', () => {
const sheet = new Sheet(":host(.foo:not(:hover)) .baz:not(:active) { color: red }")
rewriteStyleSheet(sheet as any, true)
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain(":host(.foo:not(:hover)) .baz:not(:active)")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover)) .baz:not(.pseudo-active)")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover-all)) .baz:not(:host(.pseudo-active-all) *)")
})

it('supports ":has"', () => {
const sheet = new Sheet(":has(:hover) { color: red }")
rewriteStyleSheet(sheet as any)
Expand Down
106 changes: 68 additions & 38 deletions src/preview/rewriteStyleSheet.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { PSEUDO_STATES, EXCLUDED_PSEUDO_ELEMENT_PATTERNS } from "../constants"
import { splitSelectors } from "./splitSelectors"

const pseudoStateRegExp = (global: boolean, pseudoStates: string[]) =>
new RegExp(`(?<!(${EXCLUDED_PSEUDO_ELEMENT_PATTERNS.join("|")})\\S*):(${pseudoStates.join("|")})`, global ? "g" : undefined)
const pseudoStates = Object.values(PSEUDO_STATES)
const matchOne = new RegExp(`:(${pseudoStates.join("|")})`)
const matchAll = new RegExp(`:(${pseudoStates.join("|")})`, "g")
const matchOne = pseudoStateRegExp(false, pseudoStates)
const matchAll = pseudoStateRegExp(true, pseudoStates)
const replacementRegExp = (pseudoState: string) => pseudoStateRegExp(true, [pseudoState])

const warnings = new Set()
const warnOnce = (message: string) => {
Expand All @@ -13,27 +16,57 @@ const warnOnce = (message: string) => {
warnings.add(message)
}

const replacementRegExp = (pseudoState: string) =>
new RegExp(`(?<!(${EXCLUDED_PSEUDO_ELEMENT_PATTERNS.join("|")})\\S*):${pseudoState}`, "g")
const replacePseudoStates = (selector: string, allClass?: boolean) => {
return pseudoStates.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}${allClass ? "-all" : ""}`), selector)
}

// Does not handle :host() or :not() containing pseudo-states. Need to call replaceNotSelectors on the input first.
const replacePseudoStatesWithAncestorSelector = (selector: string, forShadowDOM: boolean) => {
const { states, withoutPseudoStates } = extractPseudoStates(selector)
const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")
return states.length === 0
? selector
: forShadowDOM
? `:host(${statesAllClassSelectors}) ${withoutPseudoStates}`
: `${statesAllClassSelectors} ${withoutPseudoStates}`
}

const replacePseudoStatesWithDescendantSelector = (selector: string, forShadowDOM = false) => {
const states: string[] = []
const extractPseudoStates = (selector: string) => {
const states = new Set()
const withoutPseudoStates = selector
.replace(matchAll, (_, state) => {
states.push(state)
states.add(state)
return ""
})
// If removing pseudo-state selectors from inside a functional selector left it empty (thus invalid), must fix it by adding '*'.
.replaceAll("()", "(*)")
// If a selector list was left with blank items (e.g. ", foo, , bar, "), remove the extra commas/spaces.
.replace(/(?<=[\s(]),\s+|(,\s+)+(?=\))/g, "") || "*"

if (states.length === 0) return selector
return {
states: Array.from(states),
withoutPseudoStates
}
}

const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")
return forShadowDOM
? `:host(${statesAllClassSelectors}) ${withoutPseudoStates}`
: `${statesAllClassSelectors} ${withoutPseudoStates}`
const rewriteNotSelectors = (selector: string, forShadowDOM: boolean) => {
return [...selector.matchAll(/:not\(([^)]+)\)/g)].reduce((acc, match) => {
const originalNot = match[0]
const selectorList = match[1]
const rewrittenNot = rewriteNotSelector(selectorList, forShadowDOM)
return acc.replace(originalNot, rewrittenNot)
}, selector)
}

const rewriteNotSelector = (negatedSelectorList: string, forShadowDOM: boolean) => {
const rewrittenSelectors: string[] = []
// For each negated selector
for (const negatedSelector of negatedSelectorList.split(/,\s*/)) {
// :not cannot be nested and cannot contain pseudo-elements, so no need to worry about that.
// Also, there's no compelling use case for :host() inside :not(), so we don't handle that.
rewrittenSelectors.push(replacePseudoStatesWithAncestorSelector(negatedSelector, forShadowDOM))
}
return `:not(${rewrittenSelectors.join(", ")})`
}

const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, forShadowDOM: boolean) => {
Expand All @@ -48,44 +81,41 @@ const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, forShadowDOM: bool
return [selector]
}

const states: string[] = []
selector.replace(matchAll, (_, state) => {
states.push(state)
return ""
})
const classSelector = states.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}`), selector)
const states = new Set<string>()
const classSelector = replacePseudoStates(selector)

let ancestorSelector = ""

if (selector.startsWith(":host(")) {
const matches = selector.match(/^:host\((\S+)\)\s+(.+)$/)
if (matches && matchOne.test(matches[2])) {
// If there are pseudo-state selectors outside of :host(), then simple replacement won't work.
// E.g. :host(.foo#bar) .baz:hover:active -> :host(.foo#bar.pseudo-hover-all.pseudo-active-all) .baz
// Simple replacement won't work on pseudo-state selectors outside of :host().
// E.g. :host(.foo) .bar:hover -> :host(.foo.pseudo-hover-all) .bar
// E.g. :host(.foo:focus) .bar:hover -> :host(.foo.pseudo-focus-all.pseudo-hover-all) .bar

let hostInnerSelector = matches[1]
let descendantSelector = matches[2]
// Simple replacement is fine for pseudo-state selectors inside :host() (even if inside :not()).
hostInnerSelector = replacePseudoStates(hostInnerSelector, true)
// Rewrite any :not selectors in the descendant selector.
descendantSelector = rewriteNotSelectors(descendantSelector, true)
// Any remaining pseudo-states in the descendant selector need to be moved into the host selector.
const { states, withoutPseudoStates } = extractPseudoStates(descendantSelector)
const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")
ancestorSelector = `:host(${matches[1].replace(matchAll, "")}${statesAllClassSelectors}) ${matches[2].replace(matchAll, "")}`
ancestorSelector = `:host(${hostInnerSelector}${statesAllClassSelectors}) ${withoutPseudoStates}`
} else {
ancestorSelector = states.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}-all`), selector)
}
} else {
ancestorSelector = selector
const matches = [...selector.matchAll(/:not\(([^)]+)\)/g)]
if (matches.length) {
// For each :not(...)
for (const match of matches) {
const selectorList = match[1]
const rewrittenSelectors: string[] = []
// For each negated selector
for (const negatedSelector of selectorList.split(/,\s*/)) {
// :not cannot be nested and cannot contain pseudo-elements, so no need to worry about that.
rewrittenSelectors.push(replacePseudoStatesWithDescendantSelector(negatedSelector, forShadowDOM))
}
const rewrittenNot = `:not(${rewrittenSelectors.join(", ")})`
ancestorSelector = ancestorSelector.replace(match[0], rewrittenNot)
}
}
ancestorSelector = replacePseudoStatesWithDescendantSelector(ancestorSelector, forShadowDOM)
const withNotsReplaced = rewriteNotSelectors(selector, forShadowDOM)
const { states, withoutPseudoStates } = extractPseudoStates(withNotsReplaced)
const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")
ancestorSelector = states.length === 0
? withNotsReplaced
: forShadowDOM
? `:host(${statesAllClassSelectors}) ${withoutPseudoStates}`
: `${statesAllClassSelectors} ${withoutPseudoStates}`

}

return [selector, classSelector, ancestorSelector]
Expand Down

0 comments on commit 72c6294

Please sign in to comment.