Skip to content
Draft
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
42 changes: 40 additions & 2 deletions server/publish/siteCssBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,53 @@ export function buildPublishedSiteCssBundle(
}
}

/**
* Cascade layers for the CMS's own baseline CSS, lowest priority first.
*
* Unlayered CSS beats every cascade layer, regardless of specificity or source
* order. Emitting `reset` and `framework` unlayered therefore made them
* outrank any imported stylesheet that uses `@layer` — which is every Tailwind
* v4 build, whose entire output is wrapped in `@layer theme/base/utilities/…`.
* A site imported through the Site Import wizard rendered with the CMS's own
* tokens (`--primary`, font stack, layout defaults) silently overriding the
* imported design, while the imported CSS shipped intact but inert.
*
* Naming these layers puts the baseline below imported CSS. Order within the
* document follows first appearance, and `reset` is the first stylesheet on
* the page, so the declaration below fixes the order for both.
*
* Deliberately NOT layered: `style` (author-authored class CSS from the
* Styles panel + import). Those are explicit authoring decisions and stay
* unlayered so they keep winning over both the baseline and imported sheets.
*/
const BASELINE_LAYERS = { reset: 'instatic-reset', framework: 'instatic-framework' } as const

/** `@layer a, b;` — fixes relative order before either layer is populated. */
const BASELINE_LAYER_ORDER = `@layer ${BASELINE_LAYERS.reset}, ${BASELINE_LAYERS.framework};`

/**
* Wrap a CSS body in a named cascade layer. Empty bodies still emit the block
* so the layer is registered in the document's layer order either way.
*/
function inCascadeLayer(layer: string, css: string): string {
return `@layer ${layer} {\n${css}\n}`
}

/** Build the three page-invariant bundle files from scratch. */
function computePageInvariantBundles(
site: SiteDocument,
registry: IModuleRegistry,
options: ResponsiveCssOptions,
): PageInvariantBundles {
return {
reset: makeBundleFile('reset', PUBLISHER_RESET_CSS),
framework: makeBundleFile('framework', buildFrameworkCss(site, registry)),
reset: makeBundleFile(
'reset',
`${BASELINE_LAYER_ORDER}\n${inCascadeLayer(BASELINE_LAYERS.reset, PUBLISHER_RESET_CSS)}`,
),
framework: makeBundleFile(
'framework',
inCascadeLayer(BASELINE_LAYERS.framework, buildFrameworkCss(site, registry)),
),
style: makeBundleFile('style', collectClassCSS(site, options)),
}
}
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/canvas/classStyleInjector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,39 @@ describe('generateCanvasClassCSS', () => {
expect(css).not.toMatch(/\[data-breakpoint-id\][^{]*\{[^}]*color:\s*#000/)
})

/**
* Cascade layers. Everything this builder returns is wrapped in
* `@layer user-authored` by ClassStyleInjector, and imported stylesheets land
* in that same layer via UserStylesheetInjector. Rules sitting DIRECTLY in
* `user-authored` outrank rules in its nested sublayers, so an unlayered
* reset here beat every rule of an imported Tailwind sheet (whose output is
* entirely layered, nested one level deeper once wrapped). The canvas then
* previewed CMS defaults — `:where(body) { font-family: system-ui }` winning
* over the site's own font — while the published page rendered correctly.
*/
it('declares the baseline layer order before populating either layer', () => {
const css = generateCanvasClassCSS({}, [])
const order = css.indexOf('@layer instatic-reset, instatic-framework;')
expect(order).toBe(0)
expect(order).toBeLessThan(css.indexOf('@layer instatic-reset {'))
})

it('wraps the publisher reset inside the reset layer', () => {
const css = generateCanvasClassCSS({}, [])
const layerStart = css.indexOf('@layer instatic-reset {')
expect(layerStart).toBeGreaterThan(-1)
// The reset's rules must sit inside the layer block, not beside it.
expect(css.indexOf(':where(body)')).toBeGreaterThan(layerStart)
})

it('leaves author class CSS unlayered so it still wins', () => {
const css = generateCanvasClassCSS({ hero: makeClass('hero', { color: 'red' }) }, [])
const ruleAt = css.search(/\.hero\b/)
expect(ruleAt).toBeGreaterThan(-1)
// Nothing re-opens a baseline layer after the class rules begin.
expect(css.slice(ruleAt)).not.toContain('@layer instatic-')
})

it('uses the viewport context media query for canvas breakpoint styles', () => {
const css = generateCanvasClassCSS(
{
Expand Down
20 changes: 17 additions & 3 deletions src/__tests__/publisher/cspPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ describe('CspPlan — serialization is deterministic and sorted', () => {
it('sorts directives by name and sources within each directive', () => {
const plan = createBaseCspPlan({ anyScriptTag: false })
const csp = serializeCsp(plan)
// Directives alphabetical: default-src < frame-src < img-src < media-src
// < script-src < style-src < worker-src
// Directives alphabetical: default-src < font-src < frame-src < img-src
// < media-src < script-src < style-src < worker-src
expect(csp).toBe(
"default-src 'self'; frame-src 'none'; img-src 'self' data: https:; " +
"default-src 'self'; font-src 'self' data: https:; frame-src 'none'; " +
"img-src 'self' data: https:; " +
"media-src 'self' data: https:; " +
"script-src 'none'; style-src 'self' 'unsafe-inline'; worker-src 'none';",
)
Expand All @@ -52,6 +53,19 @@ describe('CspPlan — serialization is deterministic and sorted', () => {
expect(media).toBe(img)
})

it('lets a base64 @font-face load, exactly like a data: image', () => {
// Icon fonts routinely inline their faces as base64. Without an explicit
// `font-src` the browser fell back to `default-src 'self'` and refused
// every one — and the only symptom was a console line, because the
// stylesheet is intact and the text just renders in the fallback family.
// An imported site ships these without the author ever writing one.
const csp = serializeCsp(createBaseCspPlan({ anyScriptTag: true }))
expect(csp).toContain("font-src 'self' data: https:;")
const img = /img-src ([^;]+);/.exec(csp)?.[1]
const font = /font-src ([^;]+);/.exec(csp)?.[1]
expect(font).toBe(img)
})

it('produces a byte-identical policy regardless of source insertion order', () => {
const a = createBaseCspPlan({ anyScriptTag: true, importmapSha: 'ABC123' })
const b = createBaseCspPlan({ anyScriptTag: true, importmapSha: 'ABC123' })
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/server/siteCssBundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,4 +339,47 @@ describe('buildSiteCssBundle', () => {
expect(before.framework.content).not.toContain('.bg-primary')
expect(after.framework.content).toContain('.bg-primary')
})

/**
* Cascade layering. Unlayered CSS outranks every cascade layer regardless of
* specificity or source order, so emitting the CMS baseline unlayered made it
* silently override any imported stylesheet that uses `@layer` — i.e. every
* Tailwind v4 build, whose whole output is layered. The imported CSS shipped
* intact but inert, and the site rendered with CMS tokens instead.
*/
describe('cascade layers', () => {
it('declares the baseline layer order in reset, before either layer is used', () => {
const { reset } = buildSiteCssBundle(makeSite(), registry)
const order = reset.content.indexOf('@layer instatic-reset, instatic-framework;')
expect(order).toBe(0)
// The order statement must precede the block that populates the layer.
expect(order).toBeLessThan(reset.content.indexOf('@layer instatic-reset {'))
})

it('wraps reset and framework in the baseline layers', () => {
const site = makeSite()
site.pages = [makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } })]

const { reset, framework } = buildSiteCssBundle(site, registry)
expect(reset.content).toContain('@layer instatic-reset {')
expect(framework.content).toContain('@layer instatic-framework {')
// Module CSS travels inside the framework bundle, so it is layered too.
expect(framework.content).toContain('h1 { color: black; }')
})

it('leaves author-authored class CSS unlayered so it still wins', () => {
// `style` carries Styles-panel and imported class rules — explicit
// authoring decisions that must outrank both the baseline and any
// imported sheet. Unlayered is what buys that.
const { style } = buildSiteCssBundle(makeSite(), registry)
expect(style.content).not.toContain('@layer')
})

it('keeps user stylesheets unwrapped so their own @layer rules survive', () => {
// A kept Tailwind sheet arrives already layered. Wrapping it again would
// nest its layers under ours and re-invert the very precedence we fixed.
const { userStyles } = buildSiteCssBundle(makeSite(), registry)
expect(userStyles.content).not.toContain('@layer instatic-')
})
})
})
24 changes: 21 additions & 3 deletions src/admin/pages/site/canvas/canvasClassCss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ function buildCanvasClassCSS(
): string {
const blocks: string[] = []

// Baseline layer order, mirroring the published bundle (see
// `server/publish/siteCssBundle.ts`). Everything this function returns is
// wrapped in `@layer user-authored` by ClassStyleInjector, and the imported
// stylesheets land in that same layer via UserStylesheetInjector. Rules
// sitting DIRECTLY in `user-authored` outrank rules in its nested sublayers,
// so an unlayered reset here beat every rule of an imported Tailwind sheet
// (whose output is entirely `@layer theme/base/utilities`, nested one level
// deeper once wrapped). The canvas then previewed CMS defaults —
// `:where(body) { font-family: system-ui }` winning over the site's own
// font — while the published page, which layers these correctly, did not.
// Naming the sublayers restores the published cascade inside the iframe.
blocks.push('@layer instatic-reset, instatic-framework;')

// Publisher reset, identical to what `publishPage()` ships. Each canvas
// breakpoint frame is its own iframe with its own `<body>`, so we use the
// unscoped reset (low-specificity `:where(body) { ... }` rules) rather
Expand All @@ -43,20 +56,25 @@ function buildCanvasClassCSS(
// `body { color: var(--color-fg) }` wins over the reset's `:where(body)`
// baseline, the way it does on the live site. Editor chrome lives outside
// the iframe so the reset can't leak into the toolbars / panels.
blocks.push(PUBLISHER_RESET_CSS)
blocks.push(`@layer instatic-reset {\n${PUBLISHER_RESET_CSS}\n}`)

// Fonts go first (after the reset) so `@font-face` declarations exist before
// any rule that references the family — browsers tolerate the reverse order,
// but the ordering keeps generated CSS easier to inspect.
//
// `@font-face` is layer-neutral (it registers a family rather than matching
// an element), so grouping it with the framework costs nothing.
const baseline: string[] = []
const fontsCss = generateFontsCss(fonts)
if (fontsCss) blocks.push(fontsCss)
if (fontsCss) baseline.push(fontsCss)
const frameworkCss = generateFrameworkRootCss({
colors: frameworkColors,
typography: frameworkTypography,
spacing: frameworkSpacing,
preferences: frameworkPreferences,
})
if (frameworkCss) blocks.push(frameworkCss)
if (frameworkCss) baseline.push(frameworkCss)
if (baseline.length) blocks.push(`@layer instatic-framework {\n${baseline.join('\n\n')}\n}`)

// The registry CSS is the publisher's own generator — the canvas ships the
// exact bytes a publish would (rule order, condition/viewport cascade, and
Expand Down
8 changes: 8 additions & 0 deletions src/core/publisher/cspPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ export function createBaseCspPlan(opts: {
// invisible in the markup: the element is correct, the URL resolves, and
// only the console says why nothing happens.
setCspDirective(plan, 'media-src', ["'self'", 'data:', 'https:'])
// Fonts, for the third time and the same reason — but this one bites more
// often than the other two. Base64 `@font-face` sources are routine in
// third-party CSS (icon fonts especially), so a site brought in through
// Site Import ships them without the author ever having written one.
// Falling back to `default-src 'self'` blocked every such face, and the
// only symptom was a console line: the stylesheet is intact, the rule
// parses, the text just silently renders in the fallback family.
setCspDirective(plan, 'font-src', ["'self'", 'data:', 'https:'])
setCspDirective(plan, 'frame-src', ["'none'"])
setCspDirective(plan, 'worker-src', opts.anyScriptTag ? ["'self'", 'blob:'] : ["'none'"])
return plan
Expand Down