Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,11 @@ only when its id is used and every known class dependency in its preserved
selector is used. Ambient
selector fragments emit when at least one selector-list alternative has all of
its known class dependencies in use; class-free selectors and supported raw
blocks stay conservative. The editor canvas calls the same selector and
memoizes the filtered registry by immutable registry identity + used-id
signature, so large imported utility catalogs do not become large iframe
blocks stay conservative. Dependencies follow selector semantics: a class
inside `:not()` is never one, and the alternatives of `:is()`/`:where()` are
each sufficient rather than all required. The editor canvas calls the same
selector and memoizes the filtered registry by immutable registry identity +
used-id signature, so large imported utility catalogs do not become large iframe
stylesheets. A full precompiled Tailwind catalog can therefore remain
picker-addressable while the `style` bundle contains only selected utilities
plus global preflight.
Expand Down
115 changes: 115 additions & 0 deletions src/__tests__/publisher/styleRuleTreeShake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'bun:test'
import { treeShakeStyleRules } from '@core/publisher'
import type { StyleRule } from '@core/page-tree'

const cls = (id: string, name: string): StyleRule =>
({
id,
name,
kind: 'class',
selector: `.${name}`,
styles: {},
contextStyles: {},
order: 0,
createdAt: 0,
updatedAt: 0,
}) as unknown as StyleRule

const ambient = (id: string, selector: string): StyleRule =>
({
id,
name: selector,
kind: 'ambient',
selector,
styles: { float: 'left' },
contextStyles: {},
order: 0,
createdAt: 0,
updatedAt: 0,
}) as unknown as StyleRule

// Known and used: header, nav, p1. Known but unused: cart-button, p2, gone.
const rules: Record<string, StyleRule> = {
header: cls('header', 'header'),
nav: cls('nav', 'nav'),
cart: cls('cart', 'cart-button'),
p1: cls('p1', 'p1'),
p2: cls('p2', 'p2'),
gone: cls('gone', 'gone'),
padding: ambient('padding', '.header .nav > li > a:not(.cart-button)'),
notList: ambient('notList', '.nav a:not(.gone, .cart-button)'),
notCompound: ambient('notCompound', '.nav a:not(.gone.p2)'),
notComplex: ambient('notComplex', '.nav a:not(.gone .p2)'),
nestedNot: ambient('nestedNot', '.nav:not(:is(.gone, .p2)) a'),
where: ambient('where', '.header:where(.p1, .p2) .nav'),
whereDead: ambient('whereDead', '.header:where(.gone, .p2) .nav'),
is: ambient('is', ':is(.gone, .nav) a'),
isComplexAlt: ambient('isComplexAlt', ':is(.gone .p1, .header .p1) a'),
isAllComplexDead: ambient('isAllComplexDead', ':is(.gone .p1, .header .p2) a'),
isNotInside: ambient('isNotInside', ':is(.nav:not(.gone), .p2) a'),
isThenDeadOutside: ambient('isThenDeadOutside', ':is(.p1, .nav) .gone'),
has: ambient('has', '.header:has(.gone)'),
hasNot: ambient('hasNot', '.header:has(a:not(.gone))'),
nthOf: ambient('nthOf', '.nav > :nth-child(2n of .gone)'),
pseudoEl: ambient('pseudoEl', '.nav a:not(.gone)::after'),
upper: ambient('upper', '.nav a:NOT(.gone)'),
webkitAny: ambient('webkitAny', ':-webkit-any(.gone, .nav) a'),
escaped: ambient('escaped', '.hover\\:not-gone:hover .nav'),
attr: ambient('attr', '.nav a[title=":not(.gone)"]'),
unbalanced: ambient('unbalanced', '.nav a:not(.gone'),
bracketParen: ambient('bracketParen', '.nav a:not([x) .gone] .p1'),
emptyIs: ambient('emptyIs', '.nav:is() a'),
unknownStillKept: ambient('unknownStillKept', '.nav .not-in-registry'),
plainDead: ambient('plainDead', '.nav .gone'),
}
const used = new Set(['header', 'nav', 'p1'])
const shaken = treeShakeStyleRules(rules, used)
const kept = (id: string) => shaken[id] !== undefined

describe('treeShakeStyleRules — negation and alternation semantics', () => {
it('does not treat a negated class as a dependency', () => {
expect(kept('padding')).toBe(true)
expect(kept('notList')).toBe(true)
expect(kept('notCompound')).toBe(true)
expect(kept('notComplex')).toBe(true)
expect(kept('nestedNot')).toBe(true)
expect(kept('pseudoEl')).toBe(true)
expect(kept('upper')).toBe(true)
expect(kept('hasNot')).toBe(true)
})

it('treats :is()/:where() arguments as alternatives, not a conjunction', () => {
expect(kept('where')).toBe(true)
expect(kept('is')).toBe(true)
expect(kept('isComplexAlt')).toBe(true)
expect(kept('isNotInside')).toBe(true)
expect(kept('webkitAny')).toBe(true)
})

it('still drops a rule whose every alternative depends on an unused class', () => {
expect(kept('whereDead')).toBe(false)
expect(kept('isAllComplexDead')).toBe(false)
})

it('still requires classes outside the alternation', () => {
expect(kept('isThenDeadOutside')).toBe(false)
expect(kept('plainDead')).toBe(false)
})

it('still treats :has() and nth-child(of) arguments as required', () => {
expect(kept('has')).toBe(false)
expect(kept('nthOf')).toBe(false)
})

it('ignores pseudo-shaped text inside escapes and attribute strings', () => {
expect(kept('escaped')).toBe(true)
expect(kept('attr')).toBe(true)
})

it('keeps malformed and empty pseudos (conservative)', () => {
expect(kept('unbalanced')).toBe(true)
expect(kept('bracketParen')).toBe(true)
expect(kept('emptyIs')).toBe(true)
expect(kept('unknownStillKept')).toBe(true)
})
})
140 changes: 139 additions & 1 deletion src/core/publisher/styleRuleTreeShake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,150 @@ export function usedStyleRuleIdSignature(
return [...collectUsedStyleRuleIds(site)].sort().join('\0')
}

const NEGATION_PSEUDOS: ReadonlySet<string> = new Set(['not'])
const ALTERNATION_PSEUDOS: ReadonlySet<string> = new Set([
'is',
'where',
'matches',
'-webkit-any',
'-moz-any',
])

interface SelectorPartShape {
/**
* The selector text outside negation and alternation pseudos. Every class
* token here must be present for the part to match (conjunction), which also
* covers `:has(.x)` and `:nth-child(n of .x)`: their arguments are kept in
* place because they demand the class just like a plain compound does.
*/
conjunctive: string
/** One entry per `:is()`/`:where()` group: the part matches only if SOME alternative can. */
alternations: string[][]
}

function readFunctionalPseudoName(selector: string, colonIndex: number): string | null {
let index = colonIndex + 1
if (selector[index] === ':') return null
let name = ''
while (index < selector.length && /[\w-]/.test(selector[index])) {
name += selector[index]
index += 1
}
if (!name || selector[index] !== '(') return null
return name.toLowerCase()
}

function findClosingParen(selector: string, openIndex: number): number {
let depth = 0
let bracketDepth = 0
let quote: '"' | "'" | null = null
for (let index = openIndex; index < selector.length; index += 1) {
const char = selector[index]
if (quote) {
if (char === '\\') index += 1
else if (char === quote) quote = null
continue
}
if (char === '"' || char === "'") quote = char
else if (char === '\\') index += 1
else if (char === '[') bracketDepth += 1
else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1)
else if (bracketDepth > 0) continue
else if (char === '(') depth += 1
else if (char === ')') {
depth -= 1
if (depth === 0) return index
}
}
return selector.length - 1
}

/**
* Split one selector-list part into the text whose classes are all required
* and the `:is()`/`:where()` groups whose alternatives are each sufficient.
*
* `:not(...)` is removed outright. A negated class is not a dependency: the
* selector matches MORE elements when that class is absent, so a class no node
* carries can never be grounds for dropping the rule. The shape that forced
* this is a theme padding every nav link with `.nav > li > a:not(.cart-button)`:
* nothing on the site carries `.cart-button`, the rule was dropped, and every
* nav link lost its padding.
*
* `extractCssSelectorClasses` reports each token's `functionalDepth`, but depth
* alone cannot tell `:not(.x)` (never required) from `:has(.x)` or
* `:nth-child(n of .x)` (required), so the pseudo name has to be read here.
*/
function splitSelectorPartShape(part: string): SelectorPartShape {
let conjunctive = ''
const alternations: string[][] = []
let quote: '"' | "'" | null = null
let attributeDepth = 0

for (let index = 0; index < part.length; index += 1) {
const char = part[index]
if (quote) {
conjunctive += char
if (char === '\\' && index + 1 < part.length) {
index += 1
conjunctive += part[index]
} else if (char === quote) quote = null
continue
}
if (char === '"' || char === "'") {
quote = char
conjunctive += char
continue
}
if (char === '\\') {
conjunctive += char
if (index + 1 < part.length) {
index += 1
conjunctive += part[index]
}
continue
}
if (char === '[') attributeDepth += 1
else if (char === ']') attributeDepth = Math.max(0, attributeDepth - 1)
if (char === ':' && attributeDepth === 0) {
const name = readFunctionalPseudoName(part, index)
if (name && (NEGATION_PSEUDOS.has(name) || ALTERNATION_PSEUDOS.has(name))) {
const openIndex = index + 1 + name.length
const closeIndex = findClosingParen(part, openIndex)
if (ALTERNATION_PSEUDOS.has(name)) {
const inner = part.slice(openIndex + 1, closeIndex)
const alternatives = splitCssSelectorList(inner)
if (alternatives.length > 0) alternations.push(alternatives)
}
index = closeIndex
continue
}
}
conjunctive += char
}

return { conjunctive, alternations }
}

function selectorPartCanMatch(
selector: string,
knownClassNames: ReadonlySet<string>,
usedClassNames: ReadonlySet<string>,
): boolean {
for (const token of extractCssSelectorClasses(selector)) {
const { conjunctive, alternations } = splitSelectorPartShape(selector)
for (const token of extractCssSelectorClasses(conjunctive)) {
if (knownClassNames.has(token.name) && !usedClassNames.has(token.name)) {
return false
}
}
for (const alternatives of alternations) {
if (
!alternatives.some((alternative) =>
selectorPartCanMatch(alternative, knownClassNames, usedClassNames),
)
) {
return false
}
}
return true
}

Expand All @@ -119,6 +253,10 @@ function selectorCanMatch(
* in at least one selector-list alternative. Class-free selectors and raw
* stylesheet blocks stay conservative because their reach cannot be inferred
* from node class ids alone.
*
* "Dependency" follows selector semantics: a class inside `:not()` is never
* one (the rule matches more when it is absent), and the alternatives of
* `:is()`/`:where()` are each sufficient rather than all required.
*/
export function treeShakeStyleRules(
styleRules: Record<string, StyleRule>,
Expand Down
Loading