From 99209ad0c374d009bc71128df2b98fe2556ef06f Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:15:00 +0200 Subject: [PATCH 1/2] feat(page-tree): show a node only when its row says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list whose items are not uniform cannot be built today. Two nodes sit in one card — a video player and a "Coming soon" caption — and exactly one belongs on any given row, decided by whether that row's video field is filled. 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, and content sites lean on it heavily; Instatic had no equivalent, only the static `hidden` switch. `visibleWhen` on a node names a source, a field and a test. The source is the set the prop bindings already use, so `currentEntry` inside a loop is that iteration's row. Two tests only — `isSet` and `isNotSet` — because comparisons against a value need an operand and a type model, and every case met so far is "does this row have one". Evaluated by the publisher beside `hidden`, where the effect is identical and only the reason differs. Deliberately NOT evaluated by the editor canvas: that is where the node gets edited, and one hidden because the preview row happens to have no video is one the author cannot click. It is the single place the two surfaces differ on purpose, and both the evaluator and this commit say so. A malformed condition parses to `undefined` and the node stays visible — the only safe direction, since the alternative is silently erasing content that was rendering fine. Dynamic detection gains rule 2c: a condition reading a request-dependent source makes the node a Layer C hole. Whether it 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 artefact for every visitor. The mutation lives in its own module rather than `mutations.ts`, which the size gate records as grandfathered debt that may only shrink. --- .../site/store/slices/site/nodeActions.ts | 21 ++++ .../pages/site/store/slices/site/types.ts | 3 + src/core/page-tree/baseNode.ts | 16 +++ src/core/page-tree/dynamicBinding.ts | 62 ++++++++++ src/core/page-tree/index.ts | 4 +- src/core/page-tree/nodeVisibility.ts | 36 ++++++ src/core/publisher/dynamicDetection.ts | 14 ++- src/core/publisher/renderNode.ts | 6 +- .../__tests__/visibilityCondition.test.ts | 108 ++++++++++++++++++ src/core/templates/dynamicBindings.ts | 28 ++++- 10 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 src/core/page-tree/nodeVisibility.ts create mode 100644 src/core/templates/__tests__/visibilityCondition.test.ts diff --git a/src/admin/pages/site/store/slices/site/nodeActions.ts b/src/admin/pages/site/store/slices/site/nodeActions.ts index 1e5698463..1b09c7139 100644 --- a/src/admin/pages/site/store/slices/site/nodeActions.ts +++ b/src/admin/pages/site/store/slices/site/nodeActions.ts @@ -24,6 +24,7 @@ import { renameNode, toggleNodeLocked, toggleNodeHidden, + setNodeVisibleWhen, moveNode, moveNodes, duplicateNode, @@ -68,6 +69,7 @@ type NodeActions = Pick< | 'wrapNode' | 'wrapNodes' | 'setNodeDynamicBinding' + | 'setNodeVisibleWhen' | 'clearNodeDynamicBinding' > @@ -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] diff --git a/src/admin/pages/site/store/slices/site/types.ts b/src/admin/pages/site/store/slices/site/types.ts index 05e5e5483..a07edb1e7 100644 --- a/src/admin/pages/site/store/slices/site/types.ts +++ b/src/admin/pages/site/store/slices/site/types.ts @@ -12,6 +12,7 @@ import type { FrameworkColorToken, FrameworkColorUtilityType, FrameworkPreferenc import type { DecorativeSiteExplorerSectionId, DynamicPropBinding, + VisibilityCondition, ExplorerPathChangePlan, Page, PageNode, @@ -232,6 +233,8 @@ export interface SiteSlice { */ wrapNodes: (nodeIds: string[], containerModuleId: string, defaults?: Record) => 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 diff --git a/src/core/page-tree/baseNode.ts b/src/core/page-tree/baseNode.ts index 827d81e93..606c4fd79 100644 --- a/src/core/page-tree/baseNode.ts +++ b/src/core/page-tree/baseNode.ts @@ -19,6 +19,7 @@ import { requireArrayField, requireStringField, } from './parseHelpers' +import { VisibilityConditionSchema, parseVisibilityCondition } from './dynamicBinding' // --------------------------------------------------------------------------- // PropBinding — used by both BaseNode (propBindings field) and VCNodeSchema @@ -90,6 +91,16 @@ export const BaseNodeSchema = Type.Object({ // When true, hidden on the canvas (still present in the tree) hidden: Type.Optional(Type.Boolean()), + /** + * Show this node only when the data it renders against says so. + * + * `hidden` above is the author's own switch and is the same on every render; + * this one is evaluated per row, which is what a non-uniform list needs — a + * video player on the rows that have a video, a caption on the rows that do + * not. Absent means always visible. + */ + visibleWhen: Type.Optional(VisibilityConditionSchema), + // Ordered class IDs from the site's class registry. // Applied as the referenced user-facing class names on the element. // Later classes in the array win in cascade order. @@ -179,6 +190,10 @@ export function parseBaseNodeFields(r: Record, path: string): B // Inline styles — same tolerant bag parser as props/class styles. Dropped // when missing or empty so nodes without inline styles stay lean. const inlineStyles = parseStylesBag(r.inlineStyles) + // Malformed conditions become `undefined` — the node stays visible, which + // is the only safe direction: a bad condition must never silently erase + // content that was rendering fine. + const visibleWhen = parseVisibilityCondition(r.visibleWhen) return { id, @@ -190,6 +205,7 @@ export function parseBaseNodeFields(r: Record, path: string): B ...(typeof r.label === 'string' ? { label: r.label } : {}), ...(typeof r.locked === 'boolean' ? { locked: r.locked } : {}), ...(typeof r.hidden === 'boolean' ? { hidden: r.hidden } : {}), + ...(visibleWhen !== undefined ? { visibleWhen } : {}), ...(propBindings !== undefined ? { propBindings } : {}), ...(Object.keys(inlineStyles).length > 0 ? { inlineStyles } : {}), } diff --git a/src/core/page-tree/dynamicBinding.ts b/src/core/page-tree/dynamicBinding.ts index 14d0748ad..12dfeca8c 100644 --- a/src/core/page-tree/dynamicBinding.ts +++ b/src/core/page-tree/dynamicBinding.ts @@ -58,6 +58,68 @@ export const DynamicPropBindingSchema = Type.Object({ export type DynamicPropBinding = Static +// --------------------------------------------------------------------------- +// VisibilityCondition +// --------------------------------------------------------------------------- + +/** + * Show or hide a node based on the data it is rendered against. + * + * `hidden` on the node is the author's own switch and never changes; this is + * the per-render one. Both are checked in the same place, and either one + * hiding a node removes it and its subtree from the output. + * + * The point is a list whose items are not uniform. Two nodes sit in one card — + * a video player and a "Coming soon" caption — and exactly one belongs on any + * given row, decided by whether that row's video field is filled. Without this + * the card can only be built one way, so every row gets a player (blank for the + * rows with no video) or every row gets the caption. + * + * `isSet` is true when the field resolves to something a reader would see: a + * non-blank string, a non-empty list, any number including zero, `true`. It is + * false for absent, null, `""`, whitespace, `[]`, `{}` and `false` — an unset + * checkbox reads as unset, which is what an author picking "is set" means. + * + * The source is the same set the prop bindings use, so `currentEntry` inside a + * loop is that iteration's row and `page` / `site` / `route` work on any page. + * Deliberately only two tests: comparisons against a value need an operand and + * a type model, and every case met so far is "does this row have one". + */ +export const VisibilityConditionSchema = Type.Object({ + source: DynamicBindingSourceSchema, + field: Type.String({ minLength: 1 }), + test: Type.Union([Type.Literal('isSet'), Type.Literal('isNotSet')]), +}) + +export type VisibilityCondition = Static + +/** Parse a VisibilityCondition; anything malformed becomes `undefined`. */ +export function parseVisibilityCondition(raw: unknown): VisibilityCondition | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const r = raw as Record + const VALID_SOURCES: DynamicBindingSource[] = ['currentEntry', 'parentEntry', 'page', 'site', 'route'] + if (!VALID_SOURCES.includes(r.source as DynamicBindingSource)) return undefined + if (typeof r.field !== 'string' || r.field.length === 0) return undefined + if (r.test !== 'isSet' && r.test !== 'isNotSet') return undefined + return { source: r.source as DynamicBindingSource, field: r.field, test: r.test } +} + +/** + * Does this value count as "set"? + * + * Exported because the publisher and the editor canvas must agree exactly — + * a node that vanishes on the published page while staying put on the canvas + * is worse than either behaviour alone. + */ +export function isValueSet(value: unknown): boolean { + if (value === undefined || value === null) return false + if (typeof value === 'string') return value.trim().length > 0 + if (typeof value === 'boolean') return value + if (Array.isArray(value)) return value.length > 0 + if (typeof value === 'object') return Object.keys(value as object).length > 0 + return true +} + // --------------------------------------------------------------------------- // Tolerant parsing // --------------------------------------------------------------------------- diff --git a/src/core/page-tree/index.ts b/src/core/page-tree/index.ts index 1f9d04ae1..6c2e0a7d7 100644 --- a/src/core/page-tree/index.ts +++ b/src/core/page-tree/index.ts @@ -40,7 +40,9 @@ export { parsePageTemplate } from './pageTemplate' // Types — derived from schemas. Schemas are the source of truth. export type { Breakpoint } from './breakpoint' -export type { DynamicPropBinding } from './dynamicBinding' +export type { DynamicPropBinding, VisibilityCondition } from './dynamicBinding' +export { VisibilityConditionSchema, parseVisibilityCondition, isValueSet } from './dynamicBinding' +export { setNodeVisibleWhen } from './nodeVisibility' export type { PageTemplateConfig, TemplateTarget } from './pageTemplate' export type { PageNode } from './pageNode' export type { TreeOperation, TreeMutateResult } from './operationSchema' diff --git a/src/core/page-tree/nodeVisibility.ts b/src/core/page-tree/nodeVisibility.ts new file mode 100644 index 000000000..dc363b15e --- /dev/null +++ b/src/core/page-tree/nodeVisibility.ts @@ -0,0 +1,36 @@ +/** + * Per-render node visibility — the mutation side. + * + * The shape and its evaluator live elsewhere: `VisibilityCondition` in + * `./dynamicBinding` (beside the binding sources it reuses), and `isNodeVisible` + * in `@core/templates/dynamicBindings` (beside the render context it reads). + * This module holds only the write. + * + * Its own file rather than `./mutations` because that module is capped + * grandfathered debt — the size gate says it may shrink, never grow, and + * extracting is exactly what the gate asks for. + * + * Constraint #269: no imports from editor / editor-store here. + */ + +import type { PageNode } from './pageNode' +import type { NodeTree } from './treeSchema' +import type { VisibilityCondition } from './dynamicBinding' + +/** + * Set or clear the node's visibility condition. + * + * `undefined` clears it, which is not the same as a condition that happens to + * evaluate false: a node with no condition is always visible, and that is the + * state it returns to when the author removes the rule. + */ +export function setNodeVisibleWhen( + tree: NodeTree, + nodeId: string, + condition: VisibilityCondition | undefined, +): void { + const node = tree.nodes[nodeId] + if (!node) throw new Error(`[PageTree] Node "${nodeId}" not found`) + if (condition) node.visibleWhen = condition + else delete node.visibleWhen +} diff --git a/src/core/publisher/dynamicDetection.ts b/src/core/publisher/dynamicDetection.ts index 8aa423746..4b02a9b04 100644 --- a/src/core/publisher/dynamicDetection.ts +++ b/src/core/publisher/dynamicDetection.ts @@ -34,7 +34,7 @@ * Keeping both behind one walker means the rules cannot drift between layers. */ -import type { Page, SiteDocument, DynamicPropBinding } from '@core/page-tree' +import type { Page, SiteDocument, DynamicPropBinding, VisibilityCondition } from '@core/page-tree' import type { IModuleRegistry } from '@core/module-engine' import { selectVisualComponentById } from '@core/page-tree' import { loopSourceRegistry } from '@core/loops/registry' @@ -95,6 +95,7 @@ interface AnalysisNode { props: Record children: string[] dynamicBindings?: Record + visibleWhen?: VisibilityCondition } // --------------------------------------------------------------------------- @@ -199,6 +200,17 @@ function classifyNode( const tokenReason = checkInlineTokens(node) if (tokenReason) return { dynamic: true, reason: tokenReason } + // Rule 2c: a visibility condition reading a request-dependent source. + // 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. + if (node.visibleWhen && isBindingSourceRequestDependent(node.visibleWhen.source, node.visibleWhen.field)) { + return { + dynamic: true, + reason: `node "${node.id}": visibility depends on request-dependent "${node.visibleWhen.source}.${node.visibleWhen.field}"`, + } + } + // Rule 3: base.loop with a request-dependent source. const loopReason = checkLoopSource(node) if (loopReason) return { dynamic: true, reason: loopReason } diff --git a/src/core/publisher/renderNode.ts b/src/core/publisher/renderNode.ts index e54aec67e..ab0f1c11d 100644 --- a/src/core/publisher/renderNode.ts +++ b/src/core/publisher/renderNode.ts @@ -26,7 +26,7 @@ import { isPageRef, resolvePageRef } from '@core/page-tree' import type { AnyModuleDefinition } from '@core/module-engine' import { validateNodeProps } from '@core/module-engine' import { resolveProps } from '@core/page-tree' -import { resolveDynamicProps, effectiveNodeBindings } from '@core/templates/dynamicBindings' +import { resolveDynamicProps, effectiveNodeBindings, isNodeVisible } from '@core/templates/dynamicBindings' import { sanitizeModuleCSS } from './cssCollector' import { escapeHtml } from './utils' import { escapeProps } from './escapeProps' @@ -302,6 +302,10 @@ export function renderNode( const node = config.page.nodes[nodeId] if (!node) return '' if (node.hidden) return '' + // Per-render visibility, evaluated against the row this pass is rendering. + // Checked beside `hidden` because the effect is identical — the node and its + // subtree leave the output — and only the reason differs. + if (!isNodeVisible(node, config.templateContext)) return '' const def = config.registry.get(node.moduleId) if (!def) { diff --git a/src/core/templates/__tests__/visibilityCondition.test.ts b/src/core/templates/__tests__/visibilityCondition.test.ts new file mode 100644 index 000000000..9e19045e1 --- /dev/null +++ b/src/core/templates/__tests__/visibilityCondition.test.ts @@ -0,0 +1,108 @@ +/** + * Conditional visibility — showing a node only when its row says so. + * + * The case that motivated it: a card holds a video player and a "Coming soon" + * caption, and exactly one belongs on any given row. Without this the card can + * be built only one way, so every row gets a blank player or every row gets the + * caption. + * + * `isNodeVisible` is asked by the publisher and deliberately not by the editor + * canvas — a node hidden because the preview row has no video is a node the + * author cannot click. That asymmetry is the one place the two surfaces differ, + * so it is pinned here rather than left to be rediscovered. + */ + +import { describe, expect, test } from 'bun:test' +import { isNodeVisible } from '../dynamicBindings' +import { parseVisibilityCondition, isValueSet } from '@core/page-tree' +import type { TemplateRenderDataContext } from '../renderDataContext' + +/** A render context with one entry on the stack, as a loop iteration has. */ +const withEntry = (fields: Record): TemplateRenderDataContext => + ({ entryStack: [{ fields }] }) as unknown as TemplateRenderDataContext + +const showIfVideo = { source: 'currentEntry', field: 'video', test: 'isSet' } as const +const showIfNoVideo = { source: 'currentEntry', field: 'video', test: 'isNotSet' } as const + +describe('isNodeVisible', () => { + test('the player shows on a row that has a video', () => { + expect(isNodeVisible({ visibleWhen: showIfVideo }, withEntry({ video: '/a.mp4' }))).toBe(true) + }) + + test('the player hides on a row that has none', () => { + expect(isNodeVisible({ visibleWhen: showIfVideo }, withEntry({ video: '' }))).toBe(false) + expect(isNodeVisible({ visibleWhen: showIfVideo }, withEntry({}))).toBe(false) + }) + + test('the caption is the exact inverse', () => { + // The pair must never both show or both hide — that is the whole point. + for (const row of [{ video: '/a.mp4' }, { video: '' }, {}]) { + const player = isNodeVisible({ visibleWhen: showIfVideo }, withEntry(row)) + const caption = isNodeVisible({ visibleWhen: showIfNoVideo }, withEntry(row)) + expect(player).toBe(!caption) + } + }) + + test('a node with no condition always shows', () => { + expect(isNodeVisible({}, withEntry({}))).toBe(true) + }) + + test('no render context means visible', () => { + // A template rendered outside any entry route has nothing to test against, + // and vanishing would be a strange reading of "show when the row has a + // video" on a page that has no row. + expect(isNodeVisible({ visibleWhen: showIfVideo }, undefined)).toBe(true) + }) + + test('a missing frame reads as unset, not as an error', () => { + const noEntries = { entryStack: [] } as unknown as TemplateRenderDataContext + expect(isNodeVisible({ visibleWhen: showIfVideo }, noEntries)).toBe(false) + expect(isNodeVisible({ visibleWhen: showIfNoVideo }, noEntries)).toBe(true) + }) + + test('reads a dotted path like the prop bindings do', () => { + const cond = { source: 'currentEntry', field: 'author.name', test: 'isSet' } as const + expect(isNodeVisible({ visibleWhen: cond }, withEntry({ author: { name: 'Ada' } }))).toBe(true) + expect(isNodeVisible({ visibleWhen: cond }, withEntry({ author: { name: '' } }))).toBe(false) + }) +}) + +describe('isValueSet', () => { + test('counts what a reader would actually see', () => { + expect(isValueSet('text')).toBe(true) + expect(isValueSet(0)).toBe(true) // a real number, shown as "0" + expect(isValueSet(true)).toBe(true) + expect(isValueSet(['a'])).toBe(true) + expect(isValueSet({ a: 1 })).toBe(true) + }) + + test('and what it does not', () => { + expect(isValueSet(undefined)).toBe(false) + expect(isValueSet(null)).toBe(false) + expect(isValueSet('')).toBe(false) + expect(isValueSet(' ')).toBe(false) // whitespace is not content + expect(isValueSet([])).toBe(false) + expect(isValueSet({})).toBe(false) + expect(isValueSet(false)).toBe(false) // an unset checkbox reads as unset + }) +}) + +describe('parseVisibilityCondition', () => { + test('accepts a well-formed condition', () => { + expect(parseVisibilityCondition(showIfVideo)).toEqual({ ...showIfVideo }) + }) + + test('anything malformed becomes undefined, so the node stays visible', () => { + // The only safe direction: a bad condition must never silently erase + // content that was rendering fine. + for (const bad of [ + null, undefined, 'nope', [], + { source: 'nonsense', field: 'video', test: 'isSet' }, + { source: 'currentEntry', field: '', test: 'isSet' }, + { source: 'currentEntry', field: 'video', test: 'equals' }, + { source: 'currentEntry', field: 'video' }, + ]) { + expect(parseVisibilityCondition(bad)).toBeUndefined() + } + }) +}) diff --git a/src/core/templates/dynamicBindings.ts b/src/core/templates/dynamicBindings.ts index 941dadcec..c961d6b59 100644 --- a/src/core/templates/dynamicBindings.ts +++ b/src/core/templates/dynamicBindings.ts @@ -25,7 +25,7 @@ * the source needing to pre-render every variant. */ -import type { DynamicPropBinding } from '@core/page-tree' +import { isValueSet, type DynamicPropBinding, type VisibilityCondition } from '@core/page-tree' import { renderMarkdownToHtml } from '@core/markdown/renderMarkdown' import { isRichtextPropKey } from '@core/sanitize' import type { TemplateRenderDataContext } from './renderDataContext' @@ -87,6 +87,32 @@ function resolveBindingValue( return value } +/** + * Should this node render, given the data it is rendered against? + * + * Asked by the publisher, deliberately NOT by the editor canvas. Everywhere + * else the two surfaces are kept identical, and this is the one place they + * must differ: the canvas is where the node gets edited, and a node hidden + * because the preview row happens to have no video is a node the author cannot + * click. It stays on the canvas and the Properties panel shows its condition. + * + * Without a context the answer is always yes — a template rendered outside any + * entry route has nothing to test against, and disappearing would be a strange + * reading of "show this when the row has a video" on a page that has no row. + */ +export function isNodeVisible( + node: { visibleWhen?: VisibilityCondition }, + context: TemplateRenderDataContext | undefined, +): boolean { + const condition = node.visibleWhen + if (!condition || !context) return true + const frame = readFrame(condition.source, context) + // An absent frame is an unset field, not an error: `currentEntry` outside a + // loop has nothing to read, and "is not set" is then true. + const isSet = frame ? isValueSet(walkFieldPath(frame, condition.field)) : false + return condition.test === 'isSet' ? isSet : !isSet +} + /** * The implicit binding every `base.outlet` carries: its `html` prop is filled * with the current entry's markdown body, rendered to HTML. An outlet is, by From d2735dc0bb852e0b9c1d7425eaf9eb760a80a1cb Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:15:00 +0200 Subject: [PATCH 2/2] feat(editor): set a node's visibility condition from the Properties panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a control the condition could only be written by a plugin, which left the engine feature unreachable to the person it is for. Three fields in the Attributes view — source, field name, test — plus a sentence stating the rule in words. That sentence is doing real work. The node stays on the canvas whatever the condition says, because the canvas is where it gets edited and one hidden by the preview row is one the author cannot click. So the panel is the only place the editor can honestly report what will happen at publish time, and it says so outright rather than leaving the author to wonder why nothing moved. An empty field name clears the condition instead of storing it: a half-typed rule would otherwise hide the node against a field named "", which is never what was meant. Docs updated in the same change — `hidden` vs `visibleWhen` and what counts as set in the page-tree reference, rule 2c in the publisher's detection table with why a visibility dependency is stronger than a prop-binding one. --- docs/features/publisher.md | 9 +- docs/reference/page-tree.md | 28 ++++ .../PropertiesPanel/PropertiesPanelBody.tsx | 18 ++- .../VisibilityConditionPanel.module.css | 28 ++++ .../VisibilityConditionPanel.tsx | 124 ++++++++++++++++++ 5 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.module.css create mode 100644 src/admin/pages/site/panels/PropertiesPanel/VisibilityConditionPanel.tsx 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 })} + /> +