diff --git a/docs/features/publisher.md b/docs/features/publisher.md index f7efd7b6a..ec9f309e8 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -207,8 +207,11 @@ userStyles-.css = collectUserStylesheetCss(site, page) ← author st ``` `styleRuleTreeShake.ts` computes the site-wide used class-id set once across -page and Visual Component trees. A class rule emits only when its id is used -and every known class dependency in its preserved selector is used. Ambient +page and Visual Component trees, plus every class rule whose name appears +literally in a `type: 'script'` site file (a modifier a script toggles is never +assigned to a node; see `docs/features/site-import.md`). A class rule emits +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 diff --git a/docs/features/site-import.md b/docs/features/site-import.md index 19f56f03f..d3e95af1a 100644 --- a/docs/features/site-import.md +++ b/docs/features/site-import.md @@ -273,10 +273,18 @@ Class-free ambient selectors and supported raw blocks such as `@keyframes` remain conservative and global. This retains framework cascades such as `.row` plus `.row > *` without shipping thousands of unused utilities. -Runtime code that constructs class names dynamically cannot be inferred from a -static page tree. Those classes must be assigned in the editor (including to a -hidden structural node) or the stylesheet should use `mode:'file'`, which is -the explicit non-tree-shaken escape hatch for runtime-owned CSS. +A class that only exists at runtime — a modifier a script toggles, such as +`.nav--open` — is never assigned to a node, so node class ids alone would +prune it. `collectUsedStyleRuleIds` therefore also keeps every class whose +name appears literally in a `type: 'script'` site file: the script source is +split on every character a class name cannot contain, and a class rule whose +name is among the surviving runs counts as used. This over-collects on purpose +(`add`, `length` and every other identifier in the script also land in the +set) — a false positive costs a few bytes of CSS, a false negative drops a rule +that is correct everywhere until publish. Only literal names are seen: a class +built by concatenation at runtime still cannot be inferred and must be assigned +in the editor (including to a hidden structural node) or shipped in a +`mode:'file'` stylesheet, the explicit non-tree-shaken escape hatch. The escape hatch for "this sheet's resets/styles must not leak into other pages at all" is no longer a generated scope class — it is keeping that sheet as a file (`mode: 'file'`), page-scoped via runtime config. diff --git a/src/__tests__/publisher/classStyleInjector.test.ts b/src/__tests__/publisher/classStyleInjector.test.ts index f7620ee81..2bcf72ae8 100644 --- a/src/__tests__/publisher/classStyleInjector.test.ts +++ b/src/__tests__/publisher/classStyleInjector.test.ts @@ -762,6 +762,7 @@ describe('generateClassCSS', () => { function makeSite( styleRules: SiteDocument['styleRules'], nodeClassIds: Record = {}, + files: SiteDocument['files'] = [], ): SiteDocument { const node: PageNode = { id: 'root', @@ -800,12 +801,60 @@ function makeSite( shortcuts: {}, }, styleRules, + files, createdAt: 0, updatedAt: 0, } } +function makeScript(path: string, content: string): SiteDocument['files'][number] { + return { id: path, path, type: 'script', content } as SiteDocument['files'][number] +} + describe('collectClassCSS', () => { + it('keeps a class no node carries when a runtime script names it', () => { + // The reason tree-shaking cannot work from node class ids alone: a modifier + // toggled by script is never authored onto a node, so publish saw it as + // dead and dropped it. The rule read back correctly everywhere up to + // publish, so the failure only showed on the live site. + const site = makeSite( + { + nav: makeClass('nav', { display: 'none' }), + 'nav-open': makeClass('nav-open', { display: 'flex' }, {}, 'nav--open'), + }, + { root: ['nav'] }, + [makeScript('scripts/nav.js', "button.addEventListener('click', () => menu.classList.toggle('nav--open'))")], + ) + const css = collectClassCSS(site) + expect(css).toContain('.nav {') + expect(css).toContain('.nav--open {') + expect(css).toContain('display: flex') + }) + + it('still drops a class that neither a node nor a script references', () => { + const site = makeSite( + { + nav: makeClass('nav', { display: 'none' }), + 'nav-open': makeClass('nav-open', { display: 'flex' }, {}, 'nav--open'), + orphan: makeClass('orphan', { color: 'red' }), + }, + { root: ['nav'] }, + [makeScript('scripts/nav.js', "menu.classList.toggle('nav--open')")], + ) + const css = collectClassCSS(site) + expect(css).toContain('.nav--open {') + expect(css).not.toContain('.orphan') + }) + + it('only counts script files, not other site file types', () => { + const site = makeSite( + { orphan: makeClass('orphan', { color: 'red' }) }, + {}, + [{ id: 'notes', path: 'docs/notes.md', type: 'doc', content: 'the orphan class is for later' } as SiteDocument['files'][number]], + ) + expect(collectClassCSS(site)).not.toContain('.orphan') + }) + it('emits user-authored CSS but skips framework-generated CSS', () => { const userClass = makeClass('user-class', { color: 'green' }) const frameworkClass: StyleRule = { diff --git a/src/core/publisher/styleRuleTreeShake.ts b/src/core/publisher/styleRuleTreeShake.ts index 8033396bb..854b15e79 100644 --- a/src/core/publisher/styleRuleTreeShake.ts +++ b/src/core/publisher/styleRuleTreeShake.ts @@ -6,9 +6,54 @@ import { type StyleRule, } from '@core/page-tree' -/** Collect every registry class id referenced by page and Visual Component nodes. */ +let lastScriptFiles: SiteDocument['files'] | null = null +let lastScriptRuns: Set = new Set() + +/** + * Identifier-shaped runs in a runtime script's source. + * + * A modifier a script toggles never appears on an authored node, so node class + * ids alone cannot see it. The script is the only place it is written down, in + * whatever call the author used — `classList.add('nav--open')`, a `className` + * assignment, a template literal, a lookup table of state names. Rather than + * model those shapes, split the source on everything a CSS class name cannot + * contain and keep the runs that survive. + * + * This over-collects: `add`, `length` and every other identifier in the file + * land in the set too, so a class named after one of them is kept even when no + * script really references it. That is the safe direction. The cost of a false + * positive is a few bytes of CSS; the cost of a false negative is a rule that + * is correct everywhere until publish drops it and the feature dies on the + * live site with nothing to point at. + */ +function scriptIdentifierRuns(files: SiteDocument['files']): Set { + // `usedStyleRuleIdSignature` runs inside a canvas store selector, so this is + // hit on every store change. The store snapshot is immutable, so identity on + // the files array is enough to skip re-splitting unchanged sources. + if (files === lastScriptFiles) return lastScriptRuns + + const runs = new Set() + for (const file of files) { + if (file.type !== 'script' || typeof file.content !== 'string') continue + for (const run of file.content.split(/[^A-Za-z0-9_-]+/)) { + if (run) runs.add(run) + } + } + + lastScriptFiles = files + lastScriptRuns = runs + return runs +} + +/** + * Collect every registry class id referenced by page and Visual Component + * nodes, plus every class a runtime script names. + * + * Script-referenced ids are unioned in, never subtracted, so this can only + * ever keep more CSS than node class ids alone would. + */ export function collectUsedStyleRuleIds( - site: Pick, + site: Pick, ): Set { const usedIds = new Set() for (const page of site.pages) { @@ -22,6 +67,14 @@ export function collectUsedStyleRuleIds( for (const id of node.classIds ?? []) usedIds.add(id) } } + + const runs = scriptIdentifierRuns(site.files ?? []) + if (runs.size > 0) { + for (const rule of Object.values(site.styleRules ?? {})) { + if (rule.kind === 'class' && runs.has(rule.name)) usedIds.add(rule.id) + } + } + return usedIds } @@ -30,7 +83,7 @@ export function collectUsedStyleRuleIds( * only when the set of assigned class ids changes, not for unrelated edits. */ export function usedStyleRuleIdSignature( - site: Pick, + site: Pick, ): string { return [...collectUsedStyleRuleIds(site)].sort().join('\0') }