diff --git a/docs/features/publisher.md b/docs/features/publisher.md index cf4bc0d37..3d65c7130 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -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. diff --git a/src/__tests__/publisher/styleRuleTreeShake.test.ts b/src/__tests__/publisher/styleRuleTreeShake.test.ts new file mode 100644 index 000000000..a203534c7 --- /dev/null +++ b/src/__tests__/publisher/styleRuleTreeShake.test.ts @@ -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 = { + 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) + }) +}) diff --git a/src/core/publisher/styleRuleTreeShake.ts b/src/core/publisher/styleRuleTreeShake.ts index 854b15e79..54a8b2718 100644 --- a/src/core/publisher/styleRuleTreeShake.ts +++ b/src/core/publisher/styleRuleTreeShake.ts @@ -88,16 +88,150 @@ export function usedStyleRuleIdSignature( return [...collectUsedStyleRuleIds(site)].sort().join('\0') } +const NEGATION_PSEUDOS: ReadonlySet = new Set(['not']) +const ALTERNATION_PSEUDOS: ReadonlySet = 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, usedClassNames: ReadonlySet, ): 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 } @@ -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,