Skip to content
Merged
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
7 changes: 5 additions & 2 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,11 @@ userStyles-<hash>.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
Expand Down
16 changes: 12 additions & 4 deletions docs/features/site-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/publisher/classStyleInjector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,7 @@ describe('generateClassCSS', () => {
function makeSite(
styleRules: SiteDocument['styleRules'],
nodeClassIds: Record<string, string[]> = {},
files: SiteDocument['files'] = [],
): SiteDocument {
const node: PageNode = {
id: 'root',
Expand Down Expand Up @@ -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 = {
Expand Down
59 changes: 56 additions & 3 deletions src/core/publisher/styleRuleTreeShake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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<string> {
// `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<string>()
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<SiteDocument, 'pages' | 'visualComponents'>,
site: Pick<SiteDocument, 'pages' | 'visualComponents' | 'files' | 'styleRules'>,
): Set<string> {
const usedIds = new Set<string>()
for (const page of site.pages) {
Expand All @@ -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
}

Expand All @@ -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<SiteDocument, 'pages' | 'visualComponents'>,
site: Pick<SiteDocument, 'pages' | 'visualComponents' | 'files' | 'styleRules'>,
): string {
return [...collectUsedStyleRuleIds(site)].sort().join('\0')
}
Expand Down
Loading