diff --git a/docs/features/publisher.md b/docs/features/publisher.md index c87e8eb12..063c87c2f 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -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/.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__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 `` placeholders for nodes auto-classified as request-dependent; a ~1.1 KB `IntersectionObserver` runtime lazy-loads each fragment via `/_instatic/hole/?v=&u=`. -- 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. --- @@ -40,7 +40,7 @@ src/core/publisher/ ├── userStylesheets.ts — site-level user stylesheets ├── siteCssBundle.ts — hash-named bundle composition (reset + framework + style) ├── sizesResolver.ts — `` 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/ @@ -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 `` 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. diff --git a/docs/reference/page-tree.md b/docs/reference/page-tree.md index c639acf24..c432fdbce 100644 --- a/docs/reference/page-tree.md +++ b/docs/reference/page-tree.md @@ -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. @@ -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 `` 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 @@ -117,6 +144,7 @@ All mutations live in `src/core/page-tree/mutations.ts`. They take a `NodeTree

` for all nodes reachable from `rootNodeId`. Used by callers that need the id map before pasting (e.g. to remap scoped class `scope.nodeId`). | diff --git a/src/admin/pages/site/panels/PropertiesPanel/PropertiesPanelBody.tsx b/src/admin/pages/site/panels/PropertiesPanel/PropertiesPanelBody.tsx index 20f42e8e6..4325a2b7a 100644 --- a/src/admin/pages/site/panels/PropertiesPanel/PropertiesPanelBody.tsx +++ b/src/admin/pages/site/panels/PropertiesPanel/PropertiesPanelBody.tsx @@ -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' @@ -194,11 +195,18 @@ export function PropertiesPanelBody(props: PropertiesPanelBodyProps): React.Reac onFocusClassPicker={onFocusClassPicker} /> ) : ( - + <> + + + )} ) diff --git a/src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.module.css b/src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.module.css new file mode 100644 index 000000000..326ca9bcd --- /dev/null +++ b/src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.module.css @@ -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; +} diff --git a/src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.tsx b/src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.tsx new file mode 100644 index 000000000..a9bbabb13 --- /dev/null +++ b/src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.tsx @@ -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): 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 ( +

+

+ 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. +

+ +
+ ) + } + + 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 ( +
+
+ patch({ field: e.target.value })} + /> +