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
9 changes: 6 additions & 3 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ The published output has **no framework runtime**, **no client-side hydration of

- Entry point: `publishPage(page, site, registry, options?)` in `src/core/publisher/render.ts`. Returns `{ filename, html, jsModuleIds }`, where `html` is the full document string and `jsModuleIds` are per-page module-JS candidates for the server injection pass.
- Recursion: `renderNode(nodeId, config, acc)` in `renderNode.ts`. Bottom-up walk. Two specialized renderers hook in for `base.visual-component-ref` and `base.loop`.
- Hidden nodes (`node.hidden`) are pruned at the top of `renderNode`, before unknown-module comments, dynamic holes, specialized renderers, standard rendering, or CSS collection.
- Hidden nodes are pruned at the top of `renderNode`, before unknown-module comments, dynamic holes, specialized renderers, standard rendering, or CSS collection — both the author's own `node.hidden` switch and `node.visibleWhen`, the per-render condition evaluated against the row being rendered.
- Per-node flow: render children → resolve effective + dynamic props → `escapeProps` → call `module.render(props, renderedChildren)` → collect deduped CSS → inject author class names.
- CSS is deduped by `moduleId` via `CssCollector` (~60–80% size reduction on typical pages).
- Module `render()` is a **pure function**: no DOM, no React, no side effects (Constraint #179).
- Every node's props pass through `escapeProps` before `render()` (Constraint #211).
- Server-side wrappers (`server/publish/publicRouter.ts` → `publicRenderer.ts` → `publishedHtmlPipeline.ts`) call `publishPage`, run plugin filters, and return the HTML in the visitor response.
- Output is routed through a three-layer publishing pipeline: **Layer A** bakes pages to `uploads/published/current/<route>.html` at publish time (complete documents for fully-static pages, static shells with holes for dynamic pages, atomic two-slot symlink swap). **Layer B** memoises dynamic page renders in an in-memory LRU keyed by `(urlPath, canonicalQuery)` with per-entry version tracking; `canonicalQuery` is the output of `canonicalRenderQuery()` (in `loopPrefetch.ts`), which keeps only `loop_<nodeId>_page` pagination params — arbitrary junk params collapse to `''` so they never mint new cache slots; `bumpPublishVersion()` evicts lazily and version capture at render start discards results from mid-flight publishes. **Layer C** emits `<instatic-hole>` placeholders for nodes auto-classified as request-dependent; a ~1.1 KB `IntersectionObserver` runtime lazy-loads each fragment via `/_instatic/hole/<nodeId>?v=<publishVersion>&u=<page-url>`.
- Auto-classification lives in `src/core/publisher/dynamicDetection.ts:findDynamicNodeIds` — one walker, four detection rules plus a loop body promotion step (Rule 3.5), used by `render.ts`'s empty-set static check (Layer A) and `renderNode`'s placeholder emission (Layer C). Authors don't toggle anything.
- Auto-classification lives in `src/core/publisher/dynamicDetection.ts:findDynamicNodeIds` — one walker, five detection rules plus a loop body promotion step (Rule 3.5), used by `render.ts`'s empty-set static check (Layer A) and `renderNode`'s placeholder emission (Layer C). Authors don't toggle anything.

---

Expand All @@ -40,7 +40,7 @@ src/core/publisher/
├── userStylesheets.ts — site-level user stylesheets
├── siteCssBundle.ts — hash-named bundle composition (reset + framework + style)
├── sizesResolver.ts — `<img sizes>` derived from the layout: linear width model (caps, fractions, grid tracks) per viewport tier
├── dynamicDetection.ts — Single walker for the 4 auto-detection rules; powers Layers A and C
├── dynamicDetection.ts — Single walker for the 5 auto-detection rules; powers Layers A and C
└── utils.ts — escapeHtml, isSafeUrl, safeUrl (re-exported from @core/html-sanitize); sanitiseCssValue (from @core/css-sanitize)

server/publish/
Expand Down Expand Up @@ -180,10 +180,13 @@ See [docs/features/loops.md](loops.md) for sources, filters, and registration.
| 1 | Module flagged `dynamic: true` in the registry | Node is a hole |
| 2 | Node has a `dynamicBindings` entry whose source is request-dependent (`route.query.*`) | Node is a hole |
| 2b | A string prop contains a `{source.field}` token whose source is request-dependent | Node is a hole |
| 2c | Node has a `visibleWhen` condition reading a request-dependent source | Node is a hole |
| 3 | `moduleId === 'base.loop'` AND the loop source declares `requestDependent: true` or `perVisitor: true` | Loop is a hole |
| 3.5 | `moduleId === 'base.loop'` AND the loop source is static, but its body (transitively, including nested loops and referenced VC trees) contains any request-dependent node | Loop is promoted to a single hole; all body descendants are suppressed |
| 4 | `moduleId === 'base.visual-component-ref'` whose VC definition tree contains any dynamic node | The outer VC ref node is a hole; inner VC node ids are never promoted |

**Rule 2c** is a stronger dependency than Rule 2 and worth separating for that reason. A request-dependent prop binding changes what a node *says*; a request-dependent visibility condition changes whether the node is *there at all*. Baking it would freeze one visitor's answer into the static artefact for everyone.

**Rule 3.5** prevents a broken publish artifact: if a static loop rendered its body's dynamic child as a per-node hole, the loop would emit N `<instatic-hole id="X">` elements with the same id — one per iteration — all resolving to the same context-less fragment. By promoting the loop itself to a single hole, the renderer emits one placeholder and the hole endpoint re-runs the entire loop at request time with full per-item context.

Rules 1-4 plus the Rule 3.5 promotion path route through **one predicate**, `classifyNode(node, site, registry, seenVcs)`. The main per-node pass and the static-loop-body pre-pass both route every node decision through it (the pre-pass walks the loop subtree via `collectSubtreeReasons`, calling `classifyNode` on each visited node). There is exactly one definition of "is this node request-dependent?", so the two passes cannot drift — adding a future rule is a single edit in `classifyNode`, and a static loop whose body becomes dynamic by that rule is promoted automatically.
Expand Down
28 changes: 28 additions & 0 deletions docs/reference/page-tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const BaseNodeSchema = Type.Object({
label: Type.Optional(Type.String()),
locked: Type.Optional(Type.Boolean()),
hidden: Type.Optional(Type.Boolean()),
visibleWhen: Type.Optional(VisibilityConditionSchema), // per-render visibility; see below
classIds: withFallback(Type.Array(Type.String()), []),
inlineStyles: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
// ... propBindings, etc.
Expand All @@ -74,6 +75,32 @@ The rules:

`inlineStyles` is the per-node **inline-style layer**: a camelCase CSS bag (same shape as a `StyleRule`'s `styles`) that the publisher emits as a literal `style="…"` attribute on the node's root element (or on `<body>` for the root `base.body` node). It is independent of `classIds` (a node can have both) and is **base-only** — like a real HTML `style=""` attribute it cannot be breakpoint- or condition-scoped. Values are sanitised at the publish boundary by `bagToInlineStyle` → `sanitiseCssValue`. Edited via the Properties panel's "Style inline" mode (store actions `setNodeInlineStyles` / `removeNodeInlineStyleProperty`); the HTML importer also writes it when it harvests an element's inline background image.

#### `hidden` vs `visibleWhen`

Two ways a node can be absent from the output, and they answer different questions.

`hidden` is the author's own switch, flipped from the DOM panel. It is the same on every render.

`visibleWhen` is evaluated **per render, against the data being rendered** — which is what a list with non-uniform rows needs. Two nodes sit in one card, a video player and a "Coming soon" caption, and exactly one belongs on any given row depending on whether that row's video field is filled. Without it the card is authored once, so every row gets a blank player or every row gets the caption. (Visual CMSs generally call this conditional visibility; sites migrating from one rely on it heavily.)

```ts
{ source: 'currentEntry', field: 'video', test: 'isSet' }
```

- **`source`** — the same set the prop bindings use (`currentEntry`, `parentEntry`, `page`, `site`, `route`), so inside a `base.loop` `currentEntry` is that iteration's row.
- **`field`** — dotted paths work, exactly as in a binding (`author.name`).
- **`test`** — `isSet` or `isNotSet`, and nothing else. Comparisons against a value would need an operand and a type model; every case met so far is "does this row have one".

What counts as set (`isValueSet`): a non-blank string, a non-empty array or object, any number including `0`, and `true`. Absent, `null`, `""`, whitespace, `[]`, `{}` and `false` are unset — an unset checkbox reads as unset, which is what an author picking "is set" means.

Three behaviours worth knowing:

- **The publisher hides; the editor canvas does not.** This is the one place the two surfaces differ on purpose. The canvas is where the node gets edited, and one hidden because the preview row happens to have no video is one the author cannot click. The Properties panel states the rule in words instead.
- **A malformed condition parses to `undefined`** and the node stays visible. The only safe direction — the alternative is silently erasing content that was rendering fine.
- **A condition on a request-dependent source makes the node a Layer C hole** (dynamic-detection rule 2c). Whether the node renders at all now depends on that source, which is a stronger dependency than any prop binding: baking it would freeze one request's answer into the static artefact for every visitor.

Set from the Properties panel's Attributes view, or with the `setNodeVisibleWhen` store action.

`PageNode` (in `src/core/page-tree/pageNode.ts`) extends `BaseNode` with an optional `dynamicBindings` field for template data-binding. `VCNode` (in `src/core/visualComponents/schemas.ts`) is a direct re-export — `VCNode === BaseNode`.

### Where each kind of tree lives
Expand Down Expand Up @@ -117,6 +144,7 @@ All mutations live in `src/core/page-tree/mutations.ts`. They take a `NodeTree<P
| `renameNode(tree, nodeId, label)` | Set the user-facing `label` |
| `toggleNodeLocked(tree, nodeId)` | Flip `locked` |
| `toggleNodeHidden(tree, nodeId)` | Flip `hidden` |
| `setNodeVisibleWhen(tree, nodeId, condition \| undefined)` | Set or clear the per-render visibility condition. Lives in its own module (`nodeVisibility.ts`) — `mutations.ts` is a size-capped module that may only shrink. |
| `moveNode(tree, nodeId, newParentId, newIndex)` | Re-parent + re-order |
| `moveNodes(tree, nodeIds, newParentId, newIndex)` | Same, multi-select |
| `buildSubtreeNodeIdMap(rootNodeId, nodes)` | Build a `Map<oldId, newId>` for all nodes reachable from `rootNodeId`. Used by callers that need the id map before pasting (e.g. to remap scoped class `scope.nodeId`). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Button } from '@ui/components/Button'
import { ClassPicker, type ClassPickerHandle } from './ClassPicker'
import { StyleSurface } from './StyleSurface'
import { HtmlAttributesPanel } from './HtmlAttributesPanel'
import { VisibilityConditionPanel } from './VisibilityConditionPanel'
import { ComponentRefView } from './ComponentRefView'
import { ComponentParamsOverview } from './ComponentParamsOverview'
import { ConvertToComponentButton } from './ConvertToComponentButton'
Expand Down Expand Up @@ -194,11 +195,18 @@ export function PropertiesPanelBody(props: PropertiesPanelBodyProps): React.Reac
onFocusClassPicker={onFocusClassPicker}
/>
) : (
<HtmlAttributesPanel
nodeId={selectedNode.id}
htmlAttributes={selectedNode.props.htmlAttributes}
readOnly={!permissions.canEditStructure}
/>
<>
<VisibilityConditionPanel
nodeId={selectedNode.id}
visibleWhen={selectedNode.visibleWhen}
readOnly={!permissions.canEditStructure}
/>
<HtmlAttributesPanel
nodeId={selectedNode.id}
htmlAttributes={selectedNode.props.htmlAttributes}
readOnly={!permissions.canEditStructure}
/>
</>
)}
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* VisibilityConditionPanel — node-level editor for the visibleWhen condition. */

.panel {
display: flex;
flex-direction: column;
gap: var(--space-s);
padding: var(--space-l) var(--space-xl);
}

.row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1fr);
gap: var(--space-xs);
align-items: center;
}

.hint,
.summary {
margin: 0;
font-size: var(--text-xs);
line-height: 1.5;
color: var(--text-muted);
}

.summary strong {
color: var(--text);
font-weight: 500;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Conditional visibility — show this node only when its data says so.
*
* The node stays on the canvas whatever the condition says: this is the
* surface it gets edited on, and one hidden because the preview row happens to
* have no video is one the author cannot click. The publisher is where it
* actually disappears, so the summary line below states the rule in words —
* that sentence is the only feedback the editor can honestly give.
*/

import { Button } from '@ui/components/Button'
import { Input } from '@ui/components/Input'
import { Select } from '@ui/components/Select'
import { useEditorStore } from '@site/store/store'
import type { VisibilityCondition } from '@core/page-tree'
import styles from './VisibilityConditionPanel.module.css'

const SOURCE_OPTIONS = [
{ value: 'currentEntry', label: 'This row' },
{ value: 'parentEntry', label: 'The row around it' },
{ value: 'page', label: 'This page' },
{ value: 'site', label: 'The site' },
{ value: 'route', label: 'The URL' },
]

const TEST_OPTIONS = [
{ value: 'isSet', label: 'is filled in' },
{ value: 'isNotSet', label: 'is empty' },
]

const DEFAULT_CONDITION: VisibilityCondition = {
source: 'currentEntry',
field: '',
test: 'isSet',
}

interface VisibilityConditionPanelProps {
nodeId: string
visibleWhen: VisibilityCondition | undefined
readOnly: boolean
}

export function VisibilityConditionPanel({
nodeId,
visibleWhen,
readOnly,
}: VisibilityConditionPanelProps) {
const setNodeVisibleWhen = useEditorStore((s) => s.setNodeVisibleWhen)
const condition = visibleWhen ?? null

function patch(next: Partial<VisibilityCondition>): void {
const merged = { ...(condition ?? DEFAULT_CONDITION), ...next } as VisibilityCondition
// An empty field name is a half-typed rule, not a rule. Storing it would
// hide the node against a field called "", which is never what was meant.
setNodeVisibleWhen(nodeId, merged.field.trim() ? merged : undefined)
}

if (!condition) {
return (
<div className={styles.panel}>
<p className={styles.hint}>
Always visible. Add a condition to show this only on the rows where a
field is filled in — a video player on the rows that have a video, a
caption on the rows that do not.
</p>
<Button
variant="secondary"
size="sm"
disabled={readOnly}
onClick={() => setNodeVisibleWhen(nodeId, { ...DEFAULT_CONDITION, field: 'title' })}
>
Add a condition
</Button>
</div>
)
}

const sourceLabel = SOURCE_OPTIONS.find((o) => o.value === condition.source)?.label ?? condition.source
const testLabel = TEST_OPTIONS.find((o) => o.value === condition.test)?.label ?? condition.test

return (
<div className={styles.panel}>
<div className={styles.row}>
<Select
id="visibility-source"
name="visibility-source"
fieldSize="sm"
value={condition.source}
options={SOURCE_OPTIONS}
disabled={readOnly}
onChange={(e) => patch({ source: e.target.value as VisibilityCondition['source'] })}
/>
<Input
value={condition.field}
placeholder="field name"
disabled={readOnly}
onChange={(e) => patch({ field: e.target.value })}
/>
<Select
id="visibility-test"
name="visibility-test"
fieldSize="sm"
value={condition.test}
options={TEST_OPTIONS}
disabled={readOnly}
onChange={(e) => patch({ test: e.target.value as VisibilityCondition['test'] })}
/>
</div>
<p className={styles.summary}>
Shown when <strong>{sourceLabel}</strong>&rsquo;s{' '}
<strong>{condition.field || '…'}</strong> {testLabel}. It stays on the
canvas either way so you can keep editing it.
</p>
<Button
variant="ghost"
size="sm"
disabled={readOnly}
onClick={() => setNodeVisibleWhen(nodeId, undefined)}
>
Always show
</Button>
</div>
)
}
21 changes: 21 additions & 0 deletions src/admin/pages/site/store/slices/site/nodeActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
renameNode,
toggleNodeLocked,
toggleNodeHidden,
setNodeVisibleWhen,
moveNode,
moveNodes,
duplicateNode,
Expand Down Expand Up @@ -68,6 +69,7 @@ type NodeActions = Pick<
| 'wrapNode'
| 'wrapNodes'
| 'setNodeDynamicBinding'
| 'setNodeVisibleWhen'
| 'clearNodeDynamicBinding'
>

Expand Down Expand Up @@ -562,6 +564,25 @@ export function createNodeActions(helpers: SiteSliceHelpers): NodeActions {
return wrapperId
},

setNodeVisibleWhen: (nodeId, condition) => {
mutateActiveTree((tree) => {
const node = tree.nodes[nodeId]
if (!node) return false
const current = node.visibleWhen
// No-op guard, as the sibling binding action does: an unchanged write
// still costs a collab op and an undo entry.
if (
current?.source === condition?.source
&& current?.field === condition?.field
&& current?.test === condition?.test
) {
return false
}
setNodeVisibleWhen(tree, nodeId, condition)
return true
})
},

setNodeDynamicBinding: (nodeId, propKey, binding) => {
mutateActiveTree((tree) => {
const node = tree.nodes[nodeId]
Expand Down
3 changes: 3 additions & 0 deletions src/admin/pages/site/store/slices/site/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { FrameworkColorToken, FrameworkColorUtilityType, FrameworkPreferenc
import type {
DecorativeSiteExplorerSectionId,
DynamicPropBinding,
VisibilityCondition,
ExplorerPathChangePlan,
Page,
PageNode,
Expand Down Expand Up @@ -232,6 +233,8 @@ export interface SiteSlice {
*/
wrapNodes: (nodeIds: string[], containerModuleId: string, defaults?: Record<string, unknown>) => string | null
setNodeDynamicBinding: (nodeId: string, propKey: string, binding: DynamicPropBinding) => void
/** Set or clear the per-render visibility condition; `undefined` clears it. */
setNodeVisibleWhen: (nodeId: string, condition: VisibilityCondition | undefined) => void
clearNodeDynamicBinding: (nodeId: string, propKey: string) => void

// Breakpoint mutations
Expand Down
Loading