@bettercms-ai/astro 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -227
- package/components/BcmsBlocks.astro +640 -640
- package/components/BcmsButton.astro +44 -44
- package/components/BcmsField.astro +30 -30
- package/components/BcmsForm.astro +360 -360
- package/components/BcmsImage.astro +112 -112
- package/components/BcmsLive.astro +66 -66
- package/components/BcmsLiveBlocks.astro +72 -72
- package/components/BcmsSearch.astro +23 -23
- package/components/BcmsVisualEditing.astro +26 -26
- package/dist/bindings.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware-entry.js.map +1 -1
- package/dist/routes/draft-disable.js.map +1 -1
- package/dist/routes/draft-enable.js.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/env.d.ts +35 -35
- package/package.json +86 -86
|
@@ -1,640 +1,640 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* <BcmsBlocks> — renders a page's blockJson tree natively in Astro, at parity with
|
|
4
|
-
* the React twin in @bettercms-ai/next and with the hosted renderer
|
|
5
|
-
* (src/lib/sites/render-page.ts). Every block type the Visual Editor can produce is
|
|
6
|
-
* covered: heading, text/richtext, image, button, spacer, video, columns, form,
|
|
7
|
-
* section, navbar, footer, slider, tabs and component.
|
|
8
|
-
*
|
|
9
|
-
* Anything NOT in the switch fell through to `default: null` — a silently blank
|
|
10
|
-
* region on an Astro site for a page that looked correct on the builder canvas and
|
|
11
|
-
* on a Next site. `section`, `navbar`, `footer`, `slider`, `tabs` and `richtext`
|
|
12
|
-
* were all in that hole.
|
|
13
|
-
*
|
|
14
|
-
* Style tokens (FLO-264) are applied through the SAME `blockStyleToCss` the Next
|
|
15
|
-
* adapter and the builder canvas use, so a block's padding/colour/alignment lands
|
|
16
|
-
* identically on all three surfaces instead of being dropped on Astro only.
|
|
17
|
-
*
|
|
18
|
-
* Class names mirror the hosted renderer (`bcms-text`, `bcms-btn-*`, `bcms-cols`,
|
|
19
|
-
* `bcms-section`, `bcms-slider`, …) — that has been this component's convention
|
|
20
|
-
* since it was written, and the starters' CSS is keyed off it (it `:is()`-covers
|
|
21
|
-
* both the Astro and the Next spellings).
|
|
22
|
-
*
|
|
23
|
-
* `slider` and `tabs` carry runtime behaviour. Astro has no client component model
|
|
24
|
-
* to lean on here, so they ship the same vanilla, zero-dependency scripts the hosted
|
|
25
|
-
* renderer ships — inlined once per top-level <BcmsBlocks>, and only when the tree
|
|
26
|
-
* actually contains that block (mirrors render-page.ts's `ctx.flags` gating).
|
|
27
|
-
*/
|
|
28
|
-
import type { ContentBlock } from "@bettercms-ai/types";
|
|
29
|
-
import { RESPONSIVE_CSS, blockStyleToCss, hideOnAttr, usesHideOn, visibleBlocks, windowState, blockStyleToInnerCss, splitButtonStyle, buttonIconOf, buttonIconSvg, buttonVariantOf, componentInstanceAddresses, headingPaintCss, withButtonLookProps } from "@bettercms-ai/types";
|
|
30
|
-
import { bind, bindProps } from "../dist/bindings.js";
|
|
31
|
-
import type { DeliveryComponent, DeliveryForm } from "@bettercms-ai/sdk";
|
|
32
|
-
import { applyOverrides } from "@bettercms-ai/ui/resolve";
|
|
33
|
-
import { addressFor } from "../dist/bindings.js";
|
|
34
|
-
import { portableTextToHtml } from "@bettercms-ai/richtext/html";
|
|
35
|
-
import { embeddedComponentBlock, readStructuredContent } from "@bettercms-ai/richtext";
|
|
36
|
-
import { headingParts, richtextHeadingLevel } from "@bettercms-ai/richtext/heading";
|
|
37
|
-
import BcmsImage from "./BcmsImage.astro";
|
|
38
|
-
import BcmsForm from "./BcmsForm.astro";
|
|
39
|
-
|
|
40
|
-
interface Props {
|
|
41
|
-
blocks: ContentBlock[];
|
|
42
|
-
forms?: DeliveryForm[];
|
|
43
|
-
/** Definitions for `component` blocks — pass `readComponents()` from @bettercms-ai/next
|
|
44
|
-
* or fetch `/delivery/:workspace/components`. Absent ⇒ instances render nothing. */
|
|
45
|
-
components?: DeliveryComponent[];
|
|
46
|
-
turnstileSiteKey?: string | null;
|
|
47
|
-
/** Recursion depth for nested components. Internal; do not pass. */
|
|
48
|
-
depth?: number;
|
|
49
|
-
/** Component ids already on this branch — cycle guard. Internal; do not pass. */
|
|
50
|
-
seen?: string[];
|
|
51
|
-
/** Set on every self-recursion so only the outermost instance emits the runtime
|
|
52
|
-
* scripts. Internal; do not pass. */
|
|
53
|
-
nested?: boolean;
|
|
54
|
-
/** Attributes for the container <div> this component always emits. Used only by the
|
|
55
|
-
* `component` case, so a component instance's marker rides the container its own
|
|
56
|
-
* recursion already produces instead of gaining a second <div>. Internal; do not pass. */
|
|
57
|
-
containerAttrs?: Record<string, string | undefined>;
|
|
58
|
-
/** Inside a component instance, the DEFINITION's `<blockId>__<prop>` mapped to this
|
|
59
|
-
* instance's `<id>__overrides.<key>`. AUTHORITATIVE where present: a prop the definition
|
|
60
|
-
* does not declare gets no address, rather than one that binds to no field.
|
|
61
|
-
* Internal; do not pass. */
|
|
62
|
-
fieldAddresses?: Map<string, string>;
|
|
63
|
-
/** Set when `blocks` is a component EMBEDDED in rich text: the address of that rich-text field,
|
|
64
|
-
* which names the instance (`<owner>#<_key>`), or null when the field has none — so the
|
|
65
|
-
* embed gets no addresses rather than a bare `_key` that resolves to nothing.
|
|
66
|
-
* Internal; do not pass. */
|
|
67
|
-
owner?: string | null;
|
|
68
|
-
class?: string;
|
|
69
|
-
/** The instant block windows (`block.window`) are read against. Defaults to the render time:
|
|
70
|
-
* the build for a static page, the request for a server-rendered one. */
|
|
71
|
-
now?: Date;
|
|
72
|
-
/** Draw the blocks outside their window too, marked `data-bcms-window="pending|expired"`, for
|
|
73
|
-
* a draft preview. Off, a block outside its window is not rendered, at any depth. */
|
|
74
|
-
showScheduled?: boolean;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Matches render-page.ts and @bettercms-ai/next; a component tree deeper than this is a bug. */
|
|
78
|
-
const MAX_COMPONENT_DEPTH = 10;
|
|
79
|
-
|
|
80
|
-
const {
|
|
81
|
-
blocks: all = [],
|
|
82
|
-
forms = [],
|
|
83
|
-
components = [],
|
|
84
|
-
turnstileSiteKey,
|
|
85
|
-
depth = 0,
|
|
86
|
-
seen = [],
|
|
87
|
-
nested = false,
|
|
88
|
-
containerAttrs = {},
|
|
89
|
-
fieldAddresses,
|
|
90
|
-
owner,
|
|
91
|
-
class: className,
|
|
92
|
-
now,
|
|
93
|
-
showScheduled = false,
|
|
94
|
-
} = Astro.props;
|
|
95
|
-
const at = now ?? new Date();
|
|
96
|
-
// The outermost render removes every block outside its window, at any depth, so each
|
|
97
|
-
// self-recursion below is handed children that are already the visible ones.
|
|
98
|
-
const blocks = showScheduled || nested ? all : visibleBlocks(all, at);
|
|
99
|
-
/** What `bind`/`bindProps` read. One object, so every call site cannot disagree. */
|
|
100
|
-
const bctx = { fieldAddresses };
|
|
101
|
-
const formMap = new Map(forms.map((f) => [f.id, f]));
|
|
102
|
-
const componentMap = new Map(components.map((c) => [c.id, c]));
|
|
103
|
-
|
|
104
|
-
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
|
105
|
-
const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).props ?? {};
|
|
106
|
-
const arr = (v: unknown): any[] => (Array.isArray(v) ? v : []);
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* `blockStyleToCss` returns a React-style declaration object (camelCase keys); Astro's
|
|
110
|
-
* `style` attribute wants a string. Same token set in ⇒ same pixels out as Next.
|
|
111
|
-
*/
|
|
112
|
-
function cssString(css: Record<string, string | number> | undefined): string | undefined {
|
|
113
|
-
if (!css) return undefined;
|
|
114
|
-
return Object.entries(css)
|
|
115
|
-
.map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}:${v}`)
|
|
116
|
-
.join(";");
|
|
117
|
-
}
|
|
118
|
-
/** A button's wrapper keeps only its placement keys; the rest paints the `<a>` (`buttonPaintAttr`). */
|
|
119
|
-
function ownStyle(block: ContentBlock): any {
|
|
120
|
-
const style = (block as { style?: any }).style;
|
|
121
|
-
return block.type === "button" ? splitButtonStyle(style).placement : style;
|
|
122
|
-
}
|
|
123
|
-
function styleAttr(block: ContentBlock): string | undefined {
|
|
124
|
-
return cssString(blockStyleToCss(ownStyle(block)));
|
|
125
|
-
}
|
|
126
|
-
/** The content column's box (`contentWidth`), nested inside the style wrapper; undefined ⇒ none. */
|
|
127
|
-
function innerStyleAttr(block: ContentBlock): string | undefined {
|
|
128
|
-
return cssString(blockStyleToInnerCss(ownStyle(block)));
|
|
129
|
-
}
|
|
130
|
-
/** A button's fill, theme, corners… on the anchor itself, as on the hosted lane and in Next. */
|
|
131
|
-
function buttonPaintAttr(block: ContentBlock): string | undefined {
|
|
132
|
-
return cssString(splitButtonStyle((block as { style?: any }).style).paint);
|
|
133
|
-
}
|
|
134
|
-
/** HEADING-STYLE-PAINT: size/weight on the <hN> itself; undefined (neither token) ⇒ no attribute. */
|
|
135
|
-
function headingPaintAttr(block: ContentBlock, level: number): string | undefined {
|
|
136
|
-
return cssString(headingPaintCss((block as { style?: any }).style, { level }));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Resolve a `component` block to the blocks it stands for. Returns null to fail soft on a
|
|
141
|
-
* deleted/unknown definition (exactly like the server renderer — a dangling reference must
|
|
142
|
-
* never break the build), and on the depth/cycle guard: a component's blockJson can embed
|
|
143
|
-
* other components and nothing validates against a cycle at write time.
|
|
144
|
-
*
|
|
145
|
-
* Lives in the frontmatter, not the template, because the Astro compiler parses `<T>` in a
|
|
146
|
-
* template expression as JSX — `applyOverrides<ContentBlock>(…)` inline emitted a bogus
|
|
147
|
-
* `<ContentBlock>` element and the file failed to compile at all.
|
|
148
|
-
*/
|
|
149
|
-
function resolveComponent(
|
|
150
|
-
p: Record<string, any>,
|
|
151
|
-
): { id: string; blocks: ContentBlock[]; props: DeliveryComponent["props"] } | null {
|
|
152
|
-
const def = componentMap.get(String(p.componentId ?? ""));
|
|
153
|
-
if (!def) return null;
|
|
154
|
-
if (depth >= MAX_COMPONENT_DEPTH || seen.includes(def.id)) return null;
|
|
155
|
-
const resolved = applyOverrides<ContentBlock>(
|
|
156
|
-
def.blockJson,
|
|
157
|
-
// A Button's instance may override its look too.
|
|
158
|
-
withButtonLookProps(def),
|
|
159
|
-
(p.overrides as Record<string, unknown>) ?? {},
|
|
160
|
-
);
|
|
161
|
-
// `props` comes back too: the caller needs the DECLARATION (which block each prop drives) to
|
|
162
|
-
// build this instance's addresses, not just the resolved tree.
|
|
163
|
-
return { id: def.id, blocks: resolved, props: def.props ?? [] };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** What an embedded component's slot looks like in the rendered html — content cannot spell a comment. */
|
|
167
|
-
const EMBED_SLOT = "<!--bcms-embed-->";
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Portable Text → the html around each embedded component, plus those components as ordinary
|
|
171
|
-
* `component` blocks. An Astro component cannot render to a string inside `portableTextToHtml`,
|
|
172
|
-
* so the hook leaves a slot and the template mounts each embed there through `<Astro.self>` —
|
|
173
|
-
* the same `component` case (resolver, cycle guard, identity attributes) a placed instance uses.
|
|
174
|
-
* `chunks` has one more entry than `embeds`.
|
|
175
|
-
*/
|
|
176
|
-
function richTextParts(structured: NonNullable<ReturnType<typeof readStructuredContent>>, base: string | null) {
|
|
177
|
-
const embeds: ContentBlock[] = [];
|
|
178
|
-
const html = portableTextToHtml(structured, {
|
|
179
|
-
...(base ? { bindingBase: base } : {}),
|
|
180
|
-
renderBlock: (v) => {
|
|
181
|
-
const block = embeddedComponentBlock(v);
|
|
182
|
-
if (!block) return undefined;
|
|
183
|
-
embeds.push(block as ContentBlock);
|
|
184
|
-
return EMBED_SLOT;
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
|
-
return { chunks: html.split(EMBED_SLOT), embeds };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Depth-limited scan for a block type anywhere in the tree, components included, so the
|
|
192
|
-
* slider/tabs runtimes only ship on pages that use them — the same gating render-page.ts
|
|
193
|
-
* does with `ctx.flags`.
|
|
194
|
-
*/
|
|
195
|
-
function containsType(list: unknown, type: string, d = 0, chain: string[] = []): boolean {
|
|
196
|
-
if (d > MAX_COMPONENT_DEPTH) return false;
|
|
197
|
-
for (const b of arr(list)) {
|
|
198
|
-
if (!b || typeof b !== "object") continue;
|
|
199
|
-
if (b.type === type) return true;
|
|
200
|
-
const p = b.props ?? {};
|
|
201
|
-
if (b.type === "component") {
|
|
202
|
-
const def = componentMap.get(String(p.componentId ?? ""));
|
|
203
|
-
if (def && !chain.includes(def.id) && containsType(def.blockJson, type, d + 1, [...chain, def.id])) return true;
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
const kids = [
|
|
207
|
-
...arr(p.children),
|
|
208
|
-
...arr(p.columns).flatMap((c: unknown) => arr(c)),
|
|
209
|
-
...arr(p.slides).flatMap((s: any) => arr(s?.children)),
|
|
210
|
-
...arr(p.tabs).flatMap((t: any) => arr(t?.children)),
|
|
211
|
-
];
|
|
212
|
-
if (containsType(kids, type, d, chain)) return true;
|
|
213
|
-
}
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const needsSlider = !nested && containsType(blocks, "slider");
|
|
218
|
-
const needsTabs = !nested && containsType(blocks, "tabs");
|
|
219
|
-
/** `RESPONSIVE_CSS` rides the page only when a block hides somewhere, so other pages are unchanged. */
|
|
220
|
-
const needsHideCss = !nested && usesHideOn([blocks, components]);
|
|
221
|
-
|
|
222
|
-
/** Props every self-recursion forwards unchanged. */
|
|
223
|
-
// `fieldAddresses` rides along: a slide or tab panel is still inside the SAME component
|
|
224
|
-
// instance, so its children keep that instance's addresses. The `component` case below
|
|
225
|
-
// overrides it with the NEW instance's map.
|
|
226
|
-
const pass = { forms, components, turnstileSiteKey, nested: true, fieldAddresses, now: at, showScheduled };
|
|
227
|
-
---
|
|
228
|
-
|
|
229
|
-
{/*
|
|
230
|
-
`data-bcms-page` marks this subtree as ours to edit — the Astro equivalent of the hosted
|
|
231
|
-
renderer's `<main class="bcms-page">` and the Next adapter's container marker. The editor gates
|
|
232
|
-
the whole section lane on a CONTAINER marker rather than on the presence of blocks, so a page
|
|
233
|
-
with NO sections yet is still recognised and can be given its first one.
|
|
234
|
-
|
|
235
|
-
Only on the outermost render: every self-recursion passes `nested: true`, and a marker on each
|
|
236
|
-
nested container would claim a column's interior is a page.
|
|
237
|
-
|
|
238
|
-
An expression comment, NOT `<!-- -->`: Astro emits HTML comments to the output, and this
|
|
239
|
-
component recurses into itself for columns/tabs/sliders/components, so an HTML comment here
|
|
240
|
-
shipped once per nesting level to every visitor (FLO-1161).
|
|
241
|
-
*/}
|
|
242
|
-
<div class={className} data-bcms-page={nested ? undefined : ""} {...containerAttrs}>
|
|
243
|
-
{needsHideCss && <style set:html={RESPONSIVE_CSS} />}
|
|
244
|
-
{blocks.map((block) => {
|
|
245
|
-
const p = anyProps(block);
|
|
246
|
-
const style = styleAttr(block);
|
|
247
|
-
const inner = innerStyleAttr(block);
|
|
248
|
-
/** `data-bcms-hide`, a hook for `RESPONSIVE_CSS`; a block that hides somewhere gets the wrapper below. */
|
|
249
|
-
const hide = hideOnAttr(ownStyle(block)) || undefined;
|
|
250
|
-
/**
|
|
251
|
-
* 🔴 `data-bcms-block` — the Visual Editor hit-tests this to find sections. It rides the
|
|
252
|
-
* block's OWN root element; it does NOT get a wrapper.
|
|
253
|
-
*
|
|
254
|
-
* A wrapper <div> is what used to be emitted here, and it was a block box around every
|
|
255
|
-
* top-level block. Two consecutive `button` blocks — whose `<a class="bcms-btn">` roots are
|
|
256
|
-
* `inline-flex` and are meant to sit side by side — were each sealed in their own block box
|
|
257
|
-
* and stacked. Measured on the live amor site: both wrappers `display:block`, width 1048,
|
|
258
|
-
* y=462 and y=540. The Next adapter never had this because React lets it `cloneElement`;
|
|
259
|
-
* Astro has no cloneElement, so the attribute is applied AT CONSTRUCTION instead — the same
|
|
260
|
-
* spread idiom this file already uses for the image binding below.
|
|
261
|
-
*
|
|
262
|
-
* `undefined` values are omitted by Astro, which is why `nested` blocks (an empty object
|
|
263
|
-
* here) emit nothing — a column's child must never look like a draggable section.
|
|
264
|
-
*
|
|
265
|
-
* When the block carries style tokens the wrapper below is its root, so the marker rides
|
|
266
|
-
* that instead and `bind` stays empty. Style tokens keep their own box: merging them onto
|
|
267
|
-
* the block's root changes behaviour (a spacer's padding is absorbed under `border-box`, a
|
|
268
|
-
* section's `padding` collides last-wins instead of adding, and a button's `text-align`
|
|
269
|
-
* would centre its label instead of the button).
|
|
270
|
-
*/
|
|
271
|
-
/** `showScheduled` only: which side of its window a drawn block is on. Rides the block's root, like the id. */
|
|
272
|
-
const scheduled = showScheduled && windowState(block, at) !== "live" ? windowState(block, at) : undefined;
|
|
273
|
-
const blockAttrs = style || inner || hide ? {} : nested ? { "data-bcms-window": scheduled } : { "data-bcms-block": block.id, "data-bcms-window": scheduled };
|
|
274
|
-
const node = (() => {
|
|
275
|
-
switch (block.type) {
|
|
276
|
-
case "heading": {
|
|
277
|
-
const hLevel = clamp(Number(p.level) || 2, 1, 6);
|
|
278
|
-
const Tag = `h${hLevel}` as keyof HTMLElementTagNameMap;
|
|
279
|
-
return <Tag style={headingPaintAttr(block, hLevel)} {...bind(block.id, "text", "text", bctx)} {...blockAttrs}>{p.text}</Tag>;
|
|
280
|
-
}
|
|
281
|
-
// `richtext` and `text` carry the same payload and differ only in the authoring
|
|
282
|
-
// surface, so delivery renders them identically — the hosted renderer does the same.
|
|
283
|
-
case "text":
|
|
284
|
-
case "richtext": {
|
|
285
|
-
/**
|
|
286
|
-
* STRUCTURED CONTENT WINS. This lane read `p.html` unconditionally while the hosted
|
|
287
|
-
* renderer preferred `p.content`, so a block whose structured value had moved past its
|
|
288
|
-
* derived cache rendered the stale cache on Astro sites only — the same per-lane
|
|
289
|
-
* divergence that once made `richtext` render nothing here at all.
|
|
290
|
-
*
|
|
291
|
-
* Serialised to a STRING rather than mounting React. `@portabletext/react` would drag
|
|
292
|
-
* `@astrojs/react` into every customer project, which is a breaking install-time change
|
|
293
|
-
* for a rendering result that is identical. `set:html` is already what this line did;
|
|
294
|
-
* the difference is that the string is now generated by our own escaping serialiser
|
|
295
|
-
* instead of coming out of storage, which REDUCES the trust surface.
|
|
296
|
-
*
|
|
297
|
-
* The per-block `#<_key>` addresses are stamped by the serialiser, so one paragraph
|
|
298
|
-
* inside the field is clickable rather than the field being one opaque string.
|
|
299
|
-
*/
|
|
300
|
-
/**
|
|
301
|
-
* `props.level` renders the rich copy AS <hN>, with the SAME shared helper the hosted
|
|
302
|
-
* renderer and @bettercms-ai/next use (src/lib/richtext/heading.ts) — a heading assembled
|
|
303
|
-
* per lane is the divergence `src/__tests__/parity.test.ts` exists to catch. Absent ⇒ the
|
|
304
|
-
* ordinary container below, byte-identical to before; a value that is not one line of
|
|
305
|
-
* inline copy falls through rather than nesting paragraphs inside a heading.
|
|
306
|
-
*/
|
|
307
|
-
const level = richtextHeadingLevel(p.level);
|
|
308
|
-
if (level) {
|
|
309
|
-
const heading = headingParts(p, addressFor(block.id, "html", bctx));
|
|
310
|
-
if (heading !== null) {
|
|
311
|
-
const HTag = `h${level}` as keyof HTMLElementTagNameMap;
|
|
312
|
-
const headingBind = heading.address
|
|
313
|
-
? { "data-bcms-field": heading.address, "data-bcms-kind": "richtext" }
|
|
314
|
-
: {};
|
|
315
|
-
return <HTag style={headingPaintAttr(block, level)} {...headingBind} {...blockAttrs} set:html={heading.inner} />;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
const structured = readStructuredContent(p);
|
|
319
|
-
if (structured) {
|
|
320
|
-
const base = addressFor(block.id, "html", bctx);
|
|
321
|
-
const { chunks, embeds } = richTextParts(structured, base);
|
|
322
|
-
return (
|
|
323
|
-
<div class="bcms-text" {...blockAttrs}>
|
|
324
|
-
{chunks.map((html, i) => (
|
|
325
|
-
<Fragment>
|
|
326
|
-
<Fragment set:html={html} />
|
|
327
|
-
{embeds[i] && <Astro.self blocks={[embeds[i]]} depth={depth} seen={seen} {...pass} owner={base} />}
|
|
328
|
-
</Fragment>
|
|
329
|
-
))}
|
|
330
|
-
</div>
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
return <div class="bcms-text" {...bind(block.id, "html", "richtext", bctx)} {...blockAttrs} set:html={p.html ?? ""} />;
|
|
334
|
-
}
|
|
335
|
-
case "image": {
|
|
336
|
-
// Matches the Next adapter: the image block is click-to-select on the Visual Editor
|
|
337
|
-
// canvas like heading/text/button, rather than dock-only. `kind="image"` means select
|
|
338
|
-
// + live src reflection, never contentEditable.
|
|
339
|
-
// `alt` rides the props lane: this element already carries the src binding and one
|
|
340
|
-
// element cannot hold two addresses.
|
|
341
|
-
const imgBind = {
|
|
342
|
-
...bind(block.id, "src", "image", bctx),
|
|
343
|
-
...bindProps(block.id, [{ prop: "alt", kind: "text", attr: "alt", present: p.alt != null }], bctx),
|
|
344
|
-
};
|
|
345
|
-
return p.caption ? (
|
|
346
|
-
<figure {...blockAttrs}>
|
|
347
|
-
<BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...imgBind} />
|
|
348
|
-
<figcaption {...bind(block.id, "caption", "text", bctx)}>{p.caption}</figcaption>
|
|
349
|
-
</figure>
|
|
350
|
-
) : (
|
|
351
|
-
<BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...imgBind} {...blockAttrs} />
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
case "button":
|
|
355
|
-
return (
|
|
356
|
-
<a
|
|
357
|
-
class={`bcms-btn bcms-btn-${buttonVariantOf(p.variant)}${p.size === "sm" || p.size === "lg" ? ` bcms-btn-${p.size}` : ""}${p.fullWidth === true ? " bcms-btn-full" : ""}`}
|
|
358
|
-
href={p.href ?? "#"}
|
|
359
|
-
style={buttonPaintAttr(block)}
|
|
360
|
-
{...(p.newTab === true ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
361
|
-
{...bind(block.id, "text", "text", bctx)}
|
|
362
|
-
{...bindProps(block.id, [{ prop: "href", kind: "url", attr: "href", present: p.href != null }], bctx)}
|
|
363
|
-
{...blockAttrs}
|
|
364
|
-
>
|
|
365
|
-
{buttonIconOf(p.icon) && <Fragment set:html={buttonIconSvg(buttonIconOf(p.icon)!, "bcms-btn-icon")} />}
|
|
366
|
-
{p.text}
|
|
367
|
-
{p.arrow === true && <Fragment set:html={buttonIconSvg("arrow-right", "bcms-btn-arrow")} />}
|
|
368
|
-
</a>
|
|
369
|
-
);
|
|
370
|
-
case "spacer":
|
|
371
|
-
return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} {...blockAttrs} />;
|
|
372
|
-
case "video":
|
|
373
|
-
return (
|
|
374
|
-
<video
|
|
375
|
-
src={p.url}
|
|
376
|
-
poster={p.poster}
|
|
377
|
-
controls
|
|
378
|
-
autoplay={!!p.autoplay}
|
|
379
|
-
muted={!!p.autoplay}
|
|
380
|
-
loop={!!p.loop}
|
|
381
|
-
playsinline
|
|
382
|
-
{...bindProps(
|
|
383
|
-
block.id,
|
|
384
|
-
[
|
|
385
|
-
{ prop: "url", kind: "url", attr: "src", present: p.url != null },
|
|
386
|
-
{ prop: "poster", kind: "url", attr: "poster", present: p.poster != null },
|
|
387
|
-
],
|
|
388
|
-
bctx,
|
|
389
|
-
)}
|
|
390
|
-
{...blockAttrs}
|
|
391
|
-
/>
|
|
392
|
-
);
|
|
393
|
-
case "columns": {
|
|
394
|
-
const cols: ContentBlock[][] = Array.isArray(p.columns) ? p.columns : [];
|
|
395
|
-
return (
|
|
396
|
-
<div
|
|
397
|
-
class="bcms-cols"
|
|
398
|
-
style={`display:grid;grid-template-columns:repeat(${cols.length || 1},1fr);gap:${Number(p.gap) || 0}px`}
|
|
399
|
-
{...blockAttrs}
|
|
400
|
-
>
|
|
401
|
-
{cols.map((col) => (
|
|
402
|
-
<div class="bcms-col">
|
|
403
|
-
<Astro.self blocks={col} depth={depth} seen={seen} {...pass} />
|
|
404
|
-
</div>
|
|
405
|
-
))}
|
|
406
|
-
</div>
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
case "form": {
|
|
410
|
-
const form = formMap.get(p.formId);
|
|
411
|
-
return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} {...blockAttrs} /> : null;
|
|
412
|
-
}
|
|
413
|
-
case "section": {
|
|
414
|
-
const inner = <Astro.self blocks={arr(p.children)} depth={depth} seen={seen} {...pass} />;
|
|
415
|
-
const mw = Number(p.maxWidth) || 0;
|
|
416
|
-
return (
|
|
417
|
-
<section
|
|
418
|
-
class="bcms-section"
|
|
419
|
-
style={[
|
|
420
|
-
p.background ? `background:${p.background}` : "",
|
|
421
|
-
p.paddingY != null ? `padding:${Number(p.paddingY) || 0}px 0` : "",
|
|
422
|
-
].filter(Boolean).join(";") || undefined}
|
|
423
|
-
{...blockAttrs}
|
|
424
|
-
>
|
|
425
|
-
{mw ? <div class="bcms-section-inner" style={`max-width:${mw}px;margin:0 auto`}>{inner}</div> : inner}
|
|
426
|
-
</section>
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
case "navbar": {
|
|
430
|
-
const logo = p.logo && typeof p.logo === "object" ? p.logo : null;
|
|
431
|
-
return (
|
|
432
|
-
<nav class="bcms-navbar" {...blockAttrs}>
|
|
433
|
-
{logo?.src ? (
|
|
434
|
-
<a class="bcms-navbar-logo" href={logo.href ?? "/"}>
|
|
435
|
-
<img
|
|
436
|
-
src={logo.src}
|
|
437
|
-
alt={logo.alt ?? ""}
|
|
438
|
-
{...bind(block.id, "logo.src", "image", bctx)}
|
|
439
|
-
{...bindProps(block.id, [{ prop: "logo.alt", kind: "text", attr: "alt", present: logo.alt != null }], bctx)}
|
|
440
|
-
/>
|
|
441
|
-
</a>
|
|
442
|
-
) : null}
|
|
443
|
-
<div class="bcms-navbar-links">
|
|
444
|
-
{/* Each link addressed by index. The old "nested under arrays" exclusion was
|
|
445
|
-
about a writer ceiling that no longer exists: setFieldPath walks N indices. */}
|
|
446
|
-
{arr(p.links).map((l: any, i: number) => (
|
|
447
|
-
<a
|
|
448
|
-
href={l?.href ?? "#"}
|
|
449
|
-
{...bind(block.id, `links[${i}].label`, "text", bctx)}
|
|
450
|
-
{...bindProps(block.id, [{ prop: `links[${i}].href`, kind: "url", attr: "href", present: l?.href != null }], bctx)}
|
|
451
|
-
>
|
|
452
|
-
{l?.label ?? ""}
|
|
453
|
-
</a>
|
|
454
|
-
))}
|
|
455
|
-
</div>
|
|
456
|
-
{p.cta?.text ? (
|
|
457
|
-
<a
|
|
458
|
-
class="bcms-btn bcms-btn-primary"
|
|
459
|
-
href={p.cta.href ?? "#"}
|
|
460
|
-
{...bind(block.id, "cta.text", "text", bctx)}
|
|
461
|
-
{...bindProps(block.id, [{ prop: "cta.href", kind: "url", attr: "href", present: p.cta.href != null }], bctx)}
|
|
462
|
-
>
|
|
463
|
-
{p.cta.text}
|
|
464
|
-
</a>
|
|
465
|
-
) : null}
|
|
466
|
-
</nav>
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
case "footer":
|
|
470
|
-
return (
|
|
471
|
-
<footer class="bcms-footer" {...blockAttrs}>
|
|
472
|
-
<div class="bcms-footer-cols">
|
|
473
|
-
{arr(p.columns).map((col: any, i: number) => (
|
|
474
|
-
<div class="bcms-footer-col">
|
|
475
|
-
{col?.heading ? <h4 {...bind(block.id, `columns[${i}].heading`, "text", bctx)}>{col.heading}</h4> : null}
|
|
476
|
-
<ul>
|
|
477
|
-
{/* Two indices deep — the deepest addressable thing on a page. */}
|
|
478
|
-
{arr(col?.links).map((l: any, j: number) => (
|
|
479
|
-
<li>
|
|
480
|
-
<a
|
|
481
|
-
href={l?.href ?? "#"}
|
|
482
|
-
{...bind(block.id, `columns[${i}].links[${j}].label`, "text", bctx)}
|
|
483
|
-
{...bindProps(block.id, [{ prop: `columns[${i}].links[${j}].href`, kind: "url", attr: "href", present: l?.href != null }], bctx)}
|
|
484
|
-
>
|
|
485
|
-
{l?.label ?? ""}
|
|
486
|
-
</a>
|
|
487
|
-
</li>
|
|
488
|
-
))}
|
|
489
|
-
</ul>
|
|
490
|
-
</div>
|
|
491
|
-
))}
|
|
492
|
-
</div>
|
|
493
|
-
{p.copyright ? <p class="bcms-footer-copy" {...bind(block.id, "copyright", "text", bctx)}>{p.copyright}</p> : null}
|
|
494
|
-
</footer>
|
|
495
|
-
);
|
|
496
|
-
case "slider":
|
|
497
|
-
// `hidden` on the inactive slides — NOT just the `data-active` hook the hosted
|
|
498
|
-
// renderer uses — because that renderer ships BASE_CSS with it and this package
|
|
499
|
-
// ships no CSS at all. Without it every slide would stack on an unstyled host.
|
|
500
|
-
// The script keeps both in sync so `.bcms-slide[data-active]` CSS still works.
|
|
501
|
-
return (
|
|
502
|
-
<div
|
|
503
|
-
class="bcms-slider"
|
|
504
|
-
data-bcms-slider
|
|
505
|
-
data-autoplay={p.autoplay ? String(Number(p.interval) || 5000) : undefined}
|
|
506
|
-
data-loop={p.loop ? "" : undefined}
|
|
507
|
-
{...blockAttrs}
|
|
508
|
-
>
|
|
509
|
-
<div class="bcms-slides">
|
|
510
|
-
{arr(p.slides).map((s: any, i: number) => (
|
|
511
|
-
<div class="bcms-slide" data-active={i === 0 ? "" : undefined} hidden={i !== 0}>
|
|
512
|
-
<Astro.self blocks={arr(s?.children)} depth={depth} seen={seen} {...pass} />
|
|
513
|
-
</div>
|
|
514
|
-
))}
|
|
515
|
-
</div>
|
|
516
|
-
<button class="bcms-slider-prev" data-prev aria-label="Previous">‹</button>
|
|
517
|
-
<button class="bcms-slider-next" data-next aria-label="Next">›</button>
|
|
518
|
-
</div>
|
|
519
|
-
);
|
|
520
|
-
case "tabs":
|
|
521
|
-
return (
|
|
522
|
-
<div class="bcms-tabs" data-bcms-tabs {...blockAttrs}>
|
|
523
|
-
<div class="bcms-tablist" role="tablist">
|
|
524
|
-
{arr(p.tabs).map((t: any, i: number) => (
|
|
525
|
-
<button
|
|
526
|
-
role="tab"
|
|
527
|
-
class="bcms-tab"
|
|
528
|
-
aria-selected={i === 0 ? "true" : "false"}
|
|
529
|
-
data-tab={String(i)}
|
|
530
|
-
{...bind(block.id, `tabs[${i}].label`, "text", bctx)}
|
|
531
|
-
>
|
|
532
|
-
{t?.label ?? ""}
|
|
533
|
-
</button>
|
|
534
|
-
))}
|
|
535
|
-
</div>
|
|
536
|
-
{arr(p.tabs).map((t: any, i: number) => (
|
|
537
|
-
<div role="tabpanel" class="bcms-tabpanel" data-panel={String(i)} hidden={i !== 0}>
|
|
538
|
-
<Astro.self blocks={arr(t?.children)} depth={depth} seen={seen} {...pass} />
|
|
539
|
-
</div>
|
|
540
|
-
))}
|
|
541
|
-
</div>
|
|
542
|
-
);
|
|
543
|
-
case "component": {
|
|
544
|
-
const resolved = resolveComponent(p);
|
|
545
|
-
if (!resolved) return null;
|
|
546
|
-
// 🔴 `fieldAddresses` AFTER `{...pass}`: the instance's own map must replace the
|
|
547
|
-
// enclosing one, not inherit it. Every stamp inside this subtree otherwise names a
|
|
548
|
-
// block belonging to the DEFINITION — shared by every instance of it, so it resolves
|
|
549
|
-
// to no field on one placement and collides into a dropped duplicate on two.
|
|
550
|
-
return (
|
|
551
|
-
<Astro.self
|
|
552
|
-
blocks={showScheduled ? resolved.blocks : visibleBlocks(resolved.blocks, at)}
|
|
553
|
-
depth={depth + 1}
|
|
554
|
-
seen={[...seen, resolved.id]}
|
|
555
|
-
containerAttrs={{
|
|
556
|
-
...blockAttrs,
|
|
557
|
-
"data-bcms-component": resolved.id,
|
|
558
|
-
"data-bcms-variant": "base",
|
|
559
|
-
"data-bcms-instance": block.id,
|
|
560
|
-
}}
|
|
561
|
-
{...pass}
|
|
562
|
-
fieldAddresses={owner === null ? new Map() : componentInstanceAddresses(block.id, resolved.props, owner)}
|
|
563
|
-
/>
|
|
564
|
-
);
|
|
565
|
-
}
|
|
566
|
-
default:
|
|
567
|
-
return null;
|
|
568
|
-
}
|
|
569
|
-
})();
|
|
570
|
-
// FLO-264: honour the block's bounded style tokens here too, so Astro output matches
|
|
571
|
-
// the builder canvas, the Next adapter and the hosted site.
|
|
572
|
-
if (node == null) return null;
|
|
573
|
-
/**
|
|
574
|
-
* The style wrapper is the ONLY wrapper left, and it is unchanged: a style token needs a box
|
|
575
|
-
* to apply to. When it exists it IS this block's root, so it carries the marker and
|
|
576
|
-
* `blockAttrs` above is empty — the two are mutually exclusive by construction, so a block
|
|
577
|
-
* never gets the attribute twice and never gets a second <div>.
|
|
578
|
-
*
|
|
579
|
-
* Everything else now carries `data-bcms-block` on its own root (see `blockAttrs` above).
|
|
580
|
-
*/
|
|
581
|
-
/**
|
|
582
|
-
* The content column (`contentWidth`) is a SECOND box inside the style wrapper, as on the
|
|
583
|
-
* hosted lane (`applyBlockStyle`) and the Next adapter: the wrapper keeps the full-bleed
|
|
584
|
-
* background and only the inner box narrows. With a column and no other style, the column
|
|
585
|
-
* box is the root and carries the marker instead.
|
|
586
|
-
*/
|
|
587
|
-
const stamp = nested ? undefined : block.id;
|
|
588
|
-
if (style && inner) return <div style={style} data-bcms-hide={hide} data-bcms-block={stamp} data-bcms-window={scheduled}><div class="bcms-block-inner" style={inner}>{node}</div></div>;
|
|
589
|
-
if (hide) return <div style={style} data-bcms-hide={hide} data-bcms-block={stamp} data-bcms-window={scheduled}>{inner ? <div class="bcms-block-inner" style={inner}>{node}</div> : node}</div>;
|
|
590
|
-
if (inner) return <div class="bcms-block-inner" style={inner} data-bcms-block={stamp} data-bcms-window={scheduled}>{node}</div>;
|
|
591
|
-
if (style) return <div style={style} data-bcms-block={stamp} data-bcms-window={scheduled}>{node}</div>;
|
|
592
|
-
return node;
|
|
593
|
-
})}
|
|
594
|
-
</div>
|
|
595
|
-
|
|
596
|
-
{needsSlider && (
|
|
597
|
-
<script is:inline>
|
|
598
|
-
// Vanilla prev/next + optional autoplay, mirroring render-page.ts's SLIDER_SCRIPT.
|
|
599
|
-
// `bcmsSliderBound` makes binding idempotent: two top-level <BcmsBlocks> on one page
|
|
600
|
-
// each inline a copy, and every copy sees every slider (the same footgun that had
|
|
601
|
-
// <BcmsForm> submitting twice).
|
|
602
|
-
document.querySelectorAll("[data-bcms-slider]").forEach(function (root) {
|
|
603
|
-
if (root.dataset.bcmsSliderBound) return;
|
|
604
|
-
root.dataset.bcmsSliderBound = "1";
|
|
605
|
-
var slides = root.querySelectorAll(".bcms-slide");
|
|
606
|
-
if (!slides.length) return;
|
|
607
|
-
var i = 0;
|
|
608
|
-
function show(n) {
|
|
609
|
-
i = (n + slides.length) % slides.length;
|
|
610
|
-
slides.forEach(function (s, k) {
|
|
611
|
-
if (k === i) { s.setAttribute("data-active", ""); s.removeAttribute("hidden"); }
|
|
612
|
-
else { s.removeAttribute("data-active"); s.setAttribute("hidden", ""); }
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
var prev = root.querySelector("[data-prev]"), next = root.querySelector("[data-next]");
|
|
616
|
-
if (prev) prev.addEventListener("click", function () { show(i - 1); });
|
|
617
|
-
if (next) next.addEventListener("click", function () { show(i + 1); });
|
|
618
|
-
var ms = parseInt(root.getAttribute("data-autoplay") || "0", 10);
|
|
619
|
-
if (ms > 0) setInterval(function () { show(i + 1); }, ms);
|
|
620
|
-
});
|
|
621
|
-
</script>
|
|
622
|
-
)}
|
|
623
|
-
|
|
624
|
-
{needsTabs && (
|
|
625
|
-
<script is:inline>
|
|
626
|
-
// Toggles aria-selected + panel visibility, mirroring render-page.ts's TABS_SCRIPT.
|
|
627
|
-
// Idempotent for the same reason as the slider runtime above.
|
|
628
|
-
document.querySelectorAll("[data-bcms-tabs]").forEach(function (root) {
|
|
629
|
-
if (root.dataset.bcmsTabsBound) return;
|
|
630
|
-
root.dataset.bcmsTabsBound = "1";
|
|
631
|
-
var tabs = root.querySelectorAll("[role=tab]"), panels = root.querySelectorAll("[role=tabpanel]");
|
|
632
|
-
tabs.forEach(function (tab, idx) {
|
|
633
|
-
tab.addEventListener("click", function () {
|
|
634
|
-
tabs.forEach(function (t, k) { t.setAttribute("aria-selected", k === idx ? "true" : "false"); });
|
|
635
|
-
panels.forEach(function (pan, k) { if (k === idx) { pan.removeAttribute("hidden"); } else { pan.setAttribute("hidden", ""); } });
|
|
636
|
-
});
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
</script>
|
|
640
|
-
)}
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <BcmsBlocks> — renders a page's blockJson tree natively in Astro, at parity with
|
|
4
|
+
* the React twin in @bettercms-ai/next and with the hosted renderer
|
|
5
|
+
* (src/lib/sites/render-page.ts). Every block type the Visual Editor can produce is
|
|
6
|
+
* covered: heading, text/richtext, image, button, spacer, video, columns, form,
|
|
7
|
+
* section, navbar, footer, slider, tabs and component.
|
|
8
|
+
*
|
|
9
|
+
* Anything NOT in the switch fell through to `default: null` — a silently blank
|
|
10
|
+
* region on an Astro site for a page that looked correct on the builder canvas and
|
|
11
|
+
* on a Next site. `section`, `navbar`, `footer`, `slider`, `tabs` and `richtext`
|
|
12
|
+
* were all in that hole.
|
|
13
|
+
*
|
|
14
|
+
* Style tokens (FLO-264) are applied through the SAME `blockStyleToCss` the Next
|
|
15
|
+
* adapter and the builder canvas use, so a block's padding/colour/alignment lands
|
|
16
|
+
* identically on all three surfaces instead of being dropped on Astro only.
|
|
17
|
+
*
|
|
18
|
+
* Class names mirror the hosted renderer (`bcms-text`, `bcms-btn-*`, `bcms-cols`,
|
|
19
|
+
* `bcms-section`, `bcms-slider`, …) — that has been this component's convention
|
|
20
|
+
* since it was written, and the starters' CSS is keyed off it (it `:is()`-covers
|
|
21
|
+
* both the Astro and the Next spellings).
|
|
22
|
+
*
|
|
23
|
+
* `slider` and `tabs` carry runtime behaviour. Astro has no client component model
|
|
24
|
+
* to lean on here, so they ship the same vanilla, zero-dependency scripts the hosted
|
|
25
|
+
* renderer ships — inlined once per top-level <BcmsBlocks>, and only when the tree
|
|
26
|
+
* actually contains that block (mirrors render-page.ts's `ctx.flags` gating).
|
|
27
|
+
*/
|
|
28
|
+
import type { ContentBlock } from "@bettercms-ai/types";
|
|
29
|
+
import { RESPONSIVE_CSS, blockStyleToCss, hideOnAttr, usesHideOn, visibleBlocks, windowState, blockStyleToInnerCss, splitButtonStyle, buttonIconOf, buttonIconSvg, buttonVariantOf, componentInstanceAddresses, headingPaintCss, withButtonLookProps } from "@bettercms-ai/types";
|
|
30
|
+
import { bind, bindProps } from "../dist/bindings.js";
|
|
31
|
+
import type { DeliveryComponent, DeliveryForm } from "@bettercms-ai/sdk";
|
|
32
|
+
import { applyOverrides } from "@bettercms-ai/ui/resolve";
|
|
33
|
+
import { addressFor } from "../dist/bindings.js";
|
|
34
|
+
import { portableTextToHtml } from "@bettercms-ai/richtext/html";
|
|
35
|
+
import { embeddedComponentBlock, readStructuredContent } from "@bettercms-ai/richtext";
|
|
36
|
+
import { headingParts, richtextHeadingLevel } from "@bettercms-ai/richtext/heading";
|
|
37
|
+
import BcmsImage from "./BcmsImage.astro";
|
|
38
|
+
import BcmsForm from "./BcmsForm.astro";
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
blocks: ContentBlock[];
|
|
42
|
+
forms?: DeliveryForm[];
|
|
43
|
+
/** Definitions for `component` blocks — pass `readComponents()` from @bettercms-ai/next
|
|
44
|
+
* or fetch `/delivery/:workspace/components`. Absent ⇒ instances render nothing. */
|
|
45
|
+
components?: DeliveryComponent[];
|
|
46
|
+
turnstileSiteKey?: string | null;
|
|
47
|
+
/** Recursion depth for nested components. Internal; do not pass. */
|
|
48
|
+
depth?: number;
|
|
49
|
+
/** Component ids already on this branch — cycle guard. Internal; do not pass. */
|
|
50
|
+
seen?: string[];
|
|
51
|
+
/** Set on every self-recursion so only the outermost instance emits the runtime
|
|
52
|
+
* scripts. Internal; do not pass. */
|
|
53
|
+
nested?: boolean;
|
|
54
|
+
/** Attributes for the container <div> this component always emits. Used only by the
|
|
55
|
+
* `component` case, so a component instance's marker rides the container its own
|
|
56
|
+
* recursion already produces instead of gaining a second <div>. Internal; do not pass. */
|
|
57
|
+
containerAttrs?: Record<string, string | undefined>;
|
|
58
|
+
/** Inside a component instance, the DEFINITION's `<blockId>__<prop>` mapped to this
|
|
59
|
+
* instance's `<id>__overrides.<key>`. AUTHORITATIVE where present: a prop the definition
|
|
60
|
+
* does not declare gets no address, rather than one that binds to no field.
|
|
61
|
+
* Internal; do not pass. */
|
|
62
|
+
fieldAddresses?: Map<string, string>;
|
|
63
|
+
/** Set when `blocks` is a component EMBEDDED in rich text: the address of that rich-text field,
|
|
64
|
+
* which names the instance (`<owner>#<_key>`), or null when the field has none — so the
|
|
65
|
+
* embed gets no addresses rather than a bare `_key` that resolves to nothing.
|
|
66
|
+
* Internal; do not pass. */
|
|
67
|
+
owner?: string | null;
|
|
68
|
+
class?: string;
|
|
69
|
+
/** The instant block windows (`block.window`) are read against. Defaults to the render time:
|
|
70
|
+
* the build for a static page, the request for a server-rendered one. */
|
|
71
|
+
now?: Date;
|
|
72
|
+
/** Draw the blocks outside their window too, marked `data-bcms-window="pending|expired"`, for
|
|
73
|
+
* a draft preview. Off, a block outside its window is not rendered, at any depth. */
|
|
74
|
+
showScheduled?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Matches render-page.ts and @bettercms-ai/next; a component tree deeper than this is a bug. */
|
|
78
|
+
const MAX_COMPONENT_DEPTH = 10;
|
|
79
|
+
|
|
80
|
+
const {
|
|
81
|
+
blocks: all = [],
|
|
82
|
+
forms = [],
|
|
83
|
+
components = [],
|
|
84
|
+
turnstileSiteKey,
|
|
85
|
+
depth = 0,
|
|
86
|
+
seen = [],
|
|
87
|
+
nested = false,
|
|
88
|
+
containerAttrs = {},
|
|
89
|
+
fieldAddresses,
|
|
90
|
+
owner,
|
|
91
|
+
class: className,
|
|
92
|
+
now,
|
|
93
|
+
showScheduled = false,
|
|
94
|
+
} = Astro.props;
|
|
95
|
+
const at = now ?? new Date();
|
|
96
|
+
// The outermost render removes every block outside its window, at any depth, so each
|
|
97
|
+
// self-recursion below is handed children that are already the visible ones.
|
|
98
|
+
const blocks = showScheduled || nested ? all : visibleBlocks(all, at);
|
|
99
|
+
/** What `bind`/`bindProps` read. One object, so every call site cannot disagree. */
|
|
100
|
+
const bctx = { fieldAddresses };
|
|
101
|
+
const formMap = new Map(forms.map((f) => [f.id, f]));
|
|
102
|
+
const componentMap = new Map(components.map((c) => [c.id, c]));
|
|
103
|
+
|
|
104
|
+
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
|
105
|
+
const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).props ?? {};
|
|
106
|
+
const arr = (v: unknown): any[] => (Array.isArray(v) ? v : []);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `blockStyleToCss` returns a React-style declaration object (camelCase keys); Astro's
|
|
110
|
+
* `style` attribute wants a string. Same token set in ⇒ same pixels out as Next.
|
|
111
|
+
*/
|
|
112
|
+
function cssString(css: Record<string, string | number> | undefined): string | undefined {
|
|
113
|
+
if (!css) return undefined;
|
|
114
|
+
return Object.entries(css)
|
|
115
|
+
.map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}:${v}`)
|
|
116
|
+
.join(";");
|
|
117
|
+
}
|
|
118
|
+
/** A button's wrapper keeps only its placement keys; the rest paints the `<a>` (`buttonPaintAttr`). */
|
|
119
|
+
function ownStyle(block: ContentBlock): any {
|
|
120
|
+
const style = (block as { style?: any }).style;
|
|
121
|
+
return block.type === "button" ? splitButtonStyle(style).placement : style;
|
|
122
|
+
}
|
|
123
|
+
function styleAttr(block: ContentBlock): string | undefined {
|
|
124
|
+
return cssString(blockStyleToCss(ownStyle(block)));
|
|
125
|
+
}
|
|
126
|
+
/** The content column's box (`contentWidth`), nested inside the style wrapper; undefined ⇒ none. */
|
|
127
|
+
function innerStyleAttr(block: ContentBlock): string | undefined {
|
|
128
|
+
return cssString(blockStyleToInnerCss(ownStyle(block)));
|
|
129
|
+
}
|
|
130
|
+
/** A button's fill, theme, corners… on the anchor itself, as on the hosted lane and in Next. */
|
|
131
|
+
function buttonPaintAttr(block: ContentBlock): string | undefined {
|
|
132
|
+
return cssString(splitButtonStyle((block as { style?: any }).style).paint);
|
|
133
|
+
}
|
|
134
|
+
/** HEADING-STYLE-PAINT: size/weight on the <hN> itself; undefined (neither token) ⇒ no attribute. */
|
|
135
|
+
function headingPaintAttr(block: ContentBlock, level: number): string | undefined {
|
|
136
|
+
return cssString(headingPaintCss((block as { style?: any }).style, { level }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a `component` block to the blocks it stands for. Returns null to fail soft on a
|
|
141
|
+
* deleted/unknown definition (exactly like the server renderer — a dangling reference must
|
|
142
|
+
* never break the build), and on the depth/cycle guard: a component's blockJson can embed
|
|
143
|
+
* other components and nothing validates against a cycle at write time.
|
|
144
|
+
*
|
|
145
|
+
* Lives in the frontmatter, not the template, because the Astro compiler parses `<T>` in a
|
|
146
|
+
* template expression as JSX — `applyOverrides<ContentBlock>(…)` inline emitted a bogus
|
|
147
|
+
* `<ContentBlock>` element and the file failed to compile at all.
|
|
148
|
+
*/
|
|
149
|
+
function resolveComponent(
|
|
150
|
+
p: Record<string, any>,
|
|
151
|
+
): { id: string; blocks: ContentBlock[]; props: DeliveryComponent["props"] } | null {
|
|
152
|
+
const def = componentMap.get(String(p.componentId ?? ""));
|
|
153
|
+
if (!def) return null;
|
|
154
|
+
if (depth >= MAX_COMPONENT_DEPTH || seen.includes(def.id)) return null;
|
|
155
|
+
const resolved = applyOverrides<ContentBlock>(
|
|
156
|
+
def.blockJson,
|
|
157
|
+
// A Button's instance may override its look too.
|
|
158
|
+
withButtonLookProps(def),
|
|
159
|
+
(p.overrides as Record<string, unknown>) ?? {},
|
|
160
|
+
);
|
|
161
|
+
// `props` comes back too: the caller needs the DECLARATION (which block each prop drives) to
|
|
162
|
+
// build this instance's addresses, not just the resolved tree.
|
|
163
|
+
return { id: def.id, blocks: resolved, props: def.props ?? [] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What an embedded component's slot looks like in the rendered html — content cannot spell a comment. */
|
|
167
|
+
const EMBED_SLOT = "<!--bcms-embed-->";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Portable Text → the html around each embedded component, plus those components as ordinary
|
|
171
|
+
* `component` blocks. An Astro component cannot render to a string inside `portableTextToHtml`,
|
|
172
|
+
* so the hook leaves a slot and the template mounts each embed there through `<Astro.self>` —
|
|
173
|
+
* the same `component` case (resolver, cycle guard, identity attributes) a placed instance uses.
|
|
174
|
+
* `chunks` has one more entry than `embeds`.
|
|
175
|
+
*/
|
|
176
|
+
function richTextParts(structured: NonNullable<ReturnType<typeof readStructuredContent>>, base: string | null) {
|
|
177
|
+
const embeds: ContentBlock[] = [];
|
|
178
|
+
const html = portableTextToHtml(structured, {
|
|
179
|
+
...(base ? { bindingBase: base } : {}),
|
|
180
|
+
renderBlock: (v) => {
|
|
181
|
+
const block = embeddedComponentBlock(v);
|
|
182
|
+
if (!block) return undefined;
|
|
183
|
+
embeds.push(block as ContentBlock);
|
|
184
|
+
return EMBED_SLOT;
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
return { chunks: html.split(EMBED_SLOT), embeds };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Depth-limited scan for a block type anywhere in the tree, components included, so the
|
|
192
|
+
* slider/tabs runtimes only ship on pages that use them — the same gating render-page.ts
|
|
193
|
+
* does with `ctx.flags`.
|
|
194
|
+
*/
|
|
195
|
+
function containsType(list: unknown, type: string, d = 0, chain: string[] = []): boolean {
|
|
196
|
+
if (d > MAX_COMPONENT_DEPTH) return false;
|
|
197
|
+
for (const b of arr(list)) {
|
|
198
|
+
if (!b || typeof b !== "object") continue;
|
|
199
|
+
if (b.type === type) return true;
|
|
200
|
+
const p = b.props ?? {};
|
|
201
|
+
if (b.type === "component") {
|
|
202
|
+
const def = componentMap.get(String(p.componentId ?? ""));
|
|
203
|
+
if (def && !chain.includes(def.id) && containsType(def.blockJson, type, d + 1, [...chain, def.id])) return true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const kids = [
|
|
207
|
+
...arr(p.children),
|
|
208
|
+
...arr(p.columns).flatMap((c: unknown) => arr(c)),
|
|
209
|
+
...arr(p.slides).flatMap((s: any) => arr(s?.children)),
|
|
210
|
+
...arr(p.tabs).flatMap((t: any) => arr(t?.children)),
|
|
211
|
+
];
|
|
212
|
+
if (containsType(kids, type, d, chain)) return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const needsSlider = !nested && containsType(blocks, "slider");
|
|
218
|
+
const needsTabs = !nested && containsType(blocks, "tabs");
|
|
219
|
+
/** `RESPONSIVE_CSS` rides the page only when a block hides somewhere, so other pages are unchanged. */
|
|
220
|
+
const needsHideCss = !nested && usesHideOn([blocks, components]);
|
|
221
|
+
|
|
222
|
+
/** Props every self-recursion forwards unchanged. */
|
|
223
|
+
// `fieldAddresses` rides along: a slide or tab panel is still inside the SAME component
|
|
224
|
+
// instance, so its children keep that instance's addresses. The `component` case below
|
|
225
|
+
// overrides it with the NEW instance's map.
|
|
226
|
+
const pass = { forms, components, turnstileSiteKey, nested: true, fieldAddresses, now: at, showScheduled };
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
{/*
|
|
230
|
+
`data-bcms-page` marks this subtree as ours to edit — the Astro equivalent of the hosted
|
|
231
|
+
renderer's `<main class="bcms-page">` and the Next adapter's container marker. The editor gates
|
|
232
|
+
the whole section lane on a CONTAINER marker rather than on the presence of blocks, so a page
|
|
233
|
+
with NO sections yet is still recognised and can be given its first one.
|
|
234
|
+
|
|
235
|
+
Only on the outermost render: every self-recursion passes `nested: true`, and a marker on each
|
|
236
|
+
nested container would claim a column's interior is a page.
|
|
237
|
+
|
|
238
|
+
An expression comment, NOT `<!-- -->`: Astro emits HTML comments to the output, and this
|
|
239
|
+
component recurses into itself for columns/tabs/sliders/components, so an HTML comment here
|
|
240
|
+
shipped once per nesting level to every visitor (FLO-1161).
|
|
241
|
+
*/}
|
|
242
|
+
<div class={className} data-bcms-page={nested ? undefined : ""} {...containerAttrs}>
|
|
243
|
+
{needsHideCss && <style set:html={RESPONSIVE_CSS} />}
|
|
244
|
+
{blocks.map((block) => {
|
|
245
|
+
const p = anyProps(block);
|
|
246
|
+
const style = styleAttr(block);
|
|
247
|
+
const inner = innerStyleAttr(block);
|
|
248
|
+
/** `data-bcms-hide`, a hook for `RESPONSIVE_CSS`; a block that hides somewhere gets the wrapper below. */
|
|
249
|
+
const hide = hideOnAttr(ownStyle(block)) || undefined;
|
|
250
|
+
/**
|
|
251
|
+
* 🔴 `data-bcms-block` — the Visual Editor hit-tests this to find sections. It rides the
|
|
252
|
+
* block's OWN root element; it does NOT get a wrapper.
|
|
253
|
+
*
|
|
254
|
+
* A wrapper <div> is what used to be emitted here, and it was a block box around every
|
|
255
|
+
* top-level block. Two consecutive `button` blocks — whose `<a class="bcms-btn">` roots are
|
|
256
|
+
* `inline-flex` and are meant to sit side by side — were each sealed in their own block box
|
|
257
|
+
* and stacked. Measured on the live amor site: both wrappers `display:block`, width 1048,
|
|
258
|
+
* y=462 and y=540. The Next adapter never had this because React lets it `cloneElement`;
|
|
259
|
+
* Astro has no cloneElement, so the attribute is applied AT CONSTRUCTION instead — the same
|
|
260
|
+
* spread idiom this file already uses for the image binding below.
|
|
261
|
+
*
|
|
262
|
+
* `undefined` values are omitted by Astro, which is why `nested` blocks (an empty object
|
|
263
|
+
* here) emit nothing — a column's child must never look like a draggable section.
|
|
264
|
+
*
|
|
265
|
+
* When the block carries style tokens the wrapper below is its root, so the marker rides
|
|
266
|
+
* that instead and `bind` stays empty. Style tokens keep their own box: merging them onto
|
|
267
|
+
* the block's root changes behaviour (a spacer's padding is absorbed under `border-box`, a
|
|
268
|
+
* section's `padding` collides last-wins instead of adding, and a button's `text-align`
|
|
269
|
+
* would centre its label instead of the button).
|
|
270
|
+
*/
|
|
271
|
+
/** `showScheduled` only: which side of its window a drawn block is on. Rides the block's root, like the id. */
|
|
272
|
+
const scheduled = showScheduled && windowState(block, at) !== "live" ? windowState(block, at) : undefined;
|
|
273
|
+
const blockAttrs = style || inner || hide ? {} : nested ? { "data-bcms-window": scheduled } : { "data-bcms-block": block.id, "data-bcms-window": scheduled };
|
|
274
|
+
const node = (() => {
|
|
275
|
+
switch (block.type) {
|
|
276
|
+
case "heading": {
|
|
277
|
+
const hLevel = clamp(Number(p.level) || 2, 1, 6);
|
|
278
|
+
const Tag = `h${hLevel}` as keyof HTMLElementTagNameMap;
|
|
279
|
+
return <Tag style={headingPaintAttr(block, hLevel)} {...bind(block.id, "text", "text", bctx)} {...blockAttrs}>{p.text}</Tag>;
|
|
280
|
+
}
|
|
281
|
+
// `richtext` and `text` carry the same payload and differ only in the authoring
|
|
282
|
+
// surface, so delivery renders them identically — the hosted renderer does the same.
|
|
283
|
+
case "text":
|
|
284
|
+
case "richtext": {
|
|
285
|
+
/**
|
|
286
|
+
* STRUCTURED CONTENT WINS. This lane read `p.html` unconditionally while the hosted
|
|
287
|
+
* renderer preferred `p.content`, so a block whose structured value had moved past its
|
|
288
|
+
* derived cache rendered the stale cache on Astro sites only — the same per-lane
|
|
289
|
+
* divergence that once made `richtext` render nothing here at all.
|
|
290
|
+
*
|
|
291
|
+
* Serialised to a STRING rather than mounting React. `@portabletext/react` would drag
|
|
292
|
+
* `@astrojs/react` into every customer project, which is a breaking install-time change
|
|
293
|
+
* for a rendering result that is identical. `set:html` is already what this line did;
|
|
294
|
+
* the difference is that the string is now generated by our own escaping serialiser
|
|
295
|
+
* instead of coming out of storage, which REDUCES the trust surface.
|
|
296
|
+
*
|
|
297
|
+
* The per-block `#<_key>` addresses are stamped by the serialiser, so one paragraph
|
|
298
|
+
* inside the field is clickable rather than the field being one opaque string.
|
|
299
|
+
*/
|
|
300
|
+
/**
|
|
301
|
+
* `props.level` renders the rich copy AS <hN>, with the SAME shared helper the hosted
|
|
302
|
+
* renderer and @bettercms-ai/next use (src/lib/richtext/heading.ts) — a heading assembled
|
|
303
|
+
* per lane is the divergence `src/__tests__/parity.test.ts` exists to catch. Absent ⇒ the
|
|
304
|
+
* ordinary container below, byte-identical to before; a value that is not one line of
|
|
305
|
+
* inline copy falls through rather than nesting paragraphs inside a heading.
|
|
306
|
+
*/
|
|
307
|
+
const level = richtextHeadingLevel(p.level);
|
|
308
|
+
if (level) {
|
|
309
|
+
const heading = headingParts(p, addressFor(block.id, "html", bctx));
|
|
310
|
+
if (heading !== null) {
|
|
311
|
+
const HTag = `h${level}` as keyof HTMLElementTagNameMap;
|
|
312
|
+
const headingBind = heading.address
|
|
313
|
+
? { "data-bcms-field": heading.address, "data-bcms-kind": "richtext" }
|
|
314
|
+
: {};
|
|
315
|
+
return <HTag style={headingPaintAttr(block, level)} {...headingBind} {...blockAttrs} set:html={heading.inner} />;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const structured = readStructuredContent(p);
|
|
319
|
+
if (structured) {
|
|
320
|
+
const base = addressFor(block.id, "html", bctx);
|
|
321
|
+
const { chunks, embeds } = richTextParts(structured, base);
|
|
322
|
+
return (
|
|
323
|
+
<div class="bcms-text" {...blockAttrs}>
|
|
324
|
+
{chunks.map((html, i) => (
|
|
325
|
+
<Fragment>
|
|
326
|
+
<Fragment set:html={html} />
|
|
327
|
+
{embeds[i] && <Astro.self blocks={[embeds[i]]} depth={depth} seen={seen} {...pass} owner={base} />}
|
|
328
|
+
</Fragment>
|
|
329
|
+
))}
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return <div class="bcms-text" {...bind(block.id, "html", "richtext", bctx)} {...blockAttrs} set:html={p.html ?? ""} />;
|
|
334
|
+
}
|
|
335
|
+
case "image": {
|
|
336
|
+
// Matches the Next adapter: the image block is click-to-select on the Visual Editor
|
|
337
|
+
// canvas like heading/text/button, rather than dock-only. `kind="image"` means select
|
|
338
|
+
// + live src reflection, never contentEditable.
|
|
339
|
+
// `alt` rides the props lane: this element already carries the src binding and one
|
|
340
|
+
// element cannot hold two addresses.
|
|
341
|
+
const imgBind = {
|
|
342
|
+
...bind(block.id, "src", "image", bctx),
|
|
343
|
+
...bindProps(block.id, [{ prop: "alt", kind: "text", attr: "alt", present: p.alt != null }], bctx),
|
|
344
|
+
};
|
|
345
|
+
return p.caption ? (
|
|
346
|
+
<figure {...blockAttrs}>
|
|
347
|
+
<BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...imgBind} />
|
|
348
|
+
<figcaption {...bind(block.id, "caption", "text", bctx)}>{p.caption}</figcaption>
|
|
349
|
+
</figure>
|
|
350
|
+
) : (
|
|
351
|
+
<BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...imgBind} {...blockAttrs} />
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
case "button":
|
|
355
|
+
return (
|
|
356
|
+
<a
|
|
357
|
+
class={`bcms-btn bcms-btn-${buttonVariantOf(p.variant)}${p.size === "sm" || p.size === "lg" ? ` bcms-btn-${p.size}` : ""}${p.fullWidth === true ? " bcms-btn-full" : ""}`}
|
|
358
|
+
href={p.href ?? "#"}
|
|
359
|
+
style={buttonPaintAttr(block)}
|
|
360
|
+
{...(p.newTab === true ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
361
|
+
{...bind(block.id, "text", "text", bctx)}
|
|
362
|
+
{...bindProps(block.id, [{ prop: "href", kind: "url", attr: "href", present: p.href != null }], bctx)}
|
|
363
|
+
{...blockAttrs}
|
|
364
|
+
>
|
|
365
|
+
{buttonIconOf(p.icon) && <Fragment set:html={buttonIconSvg(buttonIconOf(p.icon)!, "bcms-btn-icon")} />}
|
|
366
|
+
{p.text}
|
|
367
|
+
{p.arrow === true && <Fragment set:html={buttonIconSvg("arrow-right", "bcms-btn-arrow")} />}
|
|
368
|
+
</a>
|
|
369
|
+
);
|
|
370
|
+
case "spacer":
|
|
371
|
+
return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} {...blockAttrs} />;
|
|
372
|
+
case "video":
|
|
373
|
+
return (
|
|
374
|
+
<video
|
|
375
|
+
src={p.url}
|
|
376
|
+
poster={p.poster}
|
|
377
|
+
controls
|
|
378
|
+
autoplay={!!p.autoplay}
|
|
379
|
+
muted={!!p.autoplay}
|
|
380
|
+
loop={!!p.loop}
|
|
381
|
+
playsinline
|
|
382
|
+
{...bindProps(
|
|
383
|
+
block.id,
|
|
384
|
+
[
|
|
385
|
+
{ prop: "url", kind: "url", attr: "src", present: p.url != null },
|
|
386
|
+
{ prop: "poster", kind: "url", attr: "poster", present: p.poster != null },
|
|
387
|
+
],
|
|
388
|
+
bctx,
|
|
389
|
+
)}
|
|
390
|
+
{...blockAttrs}
|
|
391
|
+
/>
|
|
392
|
+
);
|
|
393
|
+
case "columns": {
|
|
394
|
+
const cols: ContentBlock[][] = Array.isArray(p.columns) ? p.columns : [];
|
|
395
|
+
return (
|
|
396
|
+
<div
|
|
397
|
+
class="bcms-cols"
|
|
398
|
+
style={`display:grid;grid-template-columns:repeat(${cols.length || 1},1fr);gap:${Number(p.gap) || 0}px`}
|
|
399
|
+
{...blockAttrs}
|
|
400
|
+
>
|
|
401
|
+
{cols.map((col) => (
|
|
402
|
+
<div class="bcms-col">
|
|
403
|
+
<Astro.self blocks={col} depth={depth} seen={seen} {...pass} />
|
|
404
|
+
</div>
|
|
405
|
+
))}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
case "form": {
|
|
410
|
+
const form = formMap.get(p.formId);
|
|
411
|
+
return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} {...blockAttrs} /> : null;
|
|
412
|
+
}
|
|
413
|
+
case "section": {
|
|
414
|
+
const inner = <Astro.self blocks={arr(p.children)} depth={depth} seen={seen} {...pass} />;
|
|
415
|
+
const mw = Number(p.maxWidth) || 0;
|
|
416
|
+
return (
|
|
417
|
+
<section
|
|
418
|
+
class="bcms-section"
|
|
419
|
+
style={[
|
|
420
|
+
p.background ? `background:${p.background}` : "",
|
|
421
|
+
p.paddingY != null ? `padding:${Number(p.paddingY) || 0}px 0` : "",
|
|
422
|
+
].filter(Boolean).join(";") || undefined}
|
|
423
|
+
{...blockAttrs}
|
|
424
|
+
>
|
|
425
|
+
{mw ? <div class="bcms-section-inner" style={`max-width:${mw}px;margin:0 auto`}>{inner}</div> : inner}
|
|
426
|
+
</section>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
case "navbar": {
|
|
430
|
+
const logo = p.logo && typeof p.logo === "object" ? p.logo : null;
|
|
431
|
+
return (
|
|
432
|
+
<nav class="bcms-navbar" {...blockAttrs}>
|
|
433
|
+
{logo?.src ? (
|
|
434
|
+
<a class="bcms-navbar-logo" href={logo.href ?? "/"}>
|
|
435
|
+
<img
|
|
436
|
+
src={logo.src}
|
|
437
|
+
alt={logo.alt ?? ""}
|
|
438
|
+
{...bind(block.id, "logo.src", "image", bctx)}
|
|
439
|
+
{...bindProps(block.id, [{ prop: "logo.alt", kind: "text", attr: "alt", present: logo.alt != null }], bctx)}
|
|
440
|
+
/>
|
|
441
|
+
</a>
|
|
442
|
+
) : null}
|
|
443
|
+
<div class="bcms-navbar-links">
|
|
444
|
+
{/* Each link addressed by index. The old "nested under arrays" exclusion was
|
|
445
|
+
about a writer ceiling that no longer exists: setFieldPath walks N indices. */}
|
|
446
|
+
{arr(p.links).map((l: any, i: number) => (
|
|
447
|
+
<a
|
|
448
|
+
href={l?.href ?? "#"}
|
|
449
|
+
{...bind(block.id, `links[${i}].label`, "text", bctx)}
|
|
450
|
+
{...bindProps(block.id, [{ prop: `links[${i}].href`, kind: "url", attr: "href", present: l?.href != null }], bctx)}
|
|
451
|
+
>
|
|
452
|
+
{l?.label ?? ""}
|
|
453
|
+
</a>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
{p.cta?.text ? (
|
|
457
|
+
<a
|
|
458
|
+
class="bcms-btn bcms-btn-primary"
|
|
459
|
+
href={p.cta.href ?? "#"}
|
|
460
|
+
{...bind(block.id, "cta.text", "text", bctx)}
|
|
461
|
+
{...bindProps(block.id, [{ prop: "cta.href", kind: "url", attr: "href", present: p.cta.href != null }], bctx)}
|
|
462
|
+
>
|
|
463
|
+
{p.cta.text}
|
|
464
|
+
</a>
|
|
465
|
+
) : null}
|
|
466
|
+
</nav>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
case "footer":
|
|
470
|
+
return (
|
|
471
|
+
<footer class="bcms-footer" {...blockAttrs}>
|
|
472
|
+
<div class="bcms-footer-cols">
|
|
473
|
+
{arr(p.columns).map((col: any, i: number) => (
|
|
474
|
+
<div class="bcms-footer-col">
|
|
475
|
+
{col?.heading ? <h4 {...bind(block.id, `columns[${i}].heading`, "text", bctx)}>{col.heading}</h4> : null}
|
|
476
|
+
<ul>
|
|
477
|
+
{/* Two indices deep — the deepest addressable thing on a page. */}
|
|
478
|
+
{arr(col?.links).map((l: any, j: number) => (
|
|
479
|
+
<li>
|
|
480
|
+
<a
|
|
481
|
+
href={l?.href ?? "#"}
|
|
482
|
+
{...bind(block.id, `columns[${i}].links[${j}].label`, "text", bctx)}
|
|
483
|
+
{...bindProps(block.id, [{ prop: `columns[${i}].links[${j}].href`, kind: "url", attr: "href", present: l?.href != null }], bctx)}
|
|
484
|
+
>
|
|
485
|
+
{l?.label ?? ""}
|
|
486
|
+
</a>
|
|
487
|
+
</li>
|
|
488
|
+
))}
|
|
489
|
+
</ul>
|
|
490
|
+
</div>
|
|
491
|
+
))}
|
|
492
|
+
</div>
|
|
493
|
+
{p.copyright ? <p class="bcms-footer-copy" {...bind(block.id, "copyright", "text", bctx)}>{p.copyright}</p> : null}
|
|
494
|
+
</footer>
|
|
495
|
+
);
|
|
496
|
+
case "slider":
|
|
497
|
+
// `hidden` on the inactive slides — NOT just the `data-active` hook the hosted
|
|
498
|
+
// renderer uses — because that renderer ships BASE_CSS with it and this package
|
|
499
|
+
// ships no CSS at all. Without it every slide would stack on an unstyled host.
|
|
500
|
+
// The script keeps both in sync so `.bcms-slide[data-active]` CSS still works.
|
|
501
|
+
return (
|
|
502
|
+
<div
|
|
503
|
+
class="bcms-slider"
|
|
504
|
+
data-bcms-slider
|
|
505
|
+
data-autoplay={p.autoplay ? String(Number(p.interval) || 5000) : undefined}
|
|
506
|
+
data-loop={p.loop ? "" : undefined}
|
|
507
|
+
{...blockAttrs}
|
|
508
|
+
>
|
|
509
|
+
<div class="bcms-slides">
|
|
510
|
+
{arr(p.slides).map((s: any, i: number) => (
|
|
511
|
+
<div class="bcms-slide" data-active={i === 0 ? "" : undefined} hidden={i !== 0}>
|
|
512
|
+
<Astro.self blocks={arr(s?.children)} depth={depth} seen={seen} {...pass} />
|
|
513
|
+
</div>
|
|
514
|
+
))}
|
|
515
|
+
</div>
|
|
516
|
+
<button class="bcms-slider-prev" data-prev aria-label="Previous">‹</button>
|
|
517
|
+
<button class="bcms-slider-next" data-next aria-label="Next">›</button>
|
|
518
|
+
</div>
|
|
519
|
+
);
|
|
520
|
+
case "tabs":
|
|
521
|
+
return (
|
|
522
|
+
<div class="bcms-tabs" data-bcms-tabs {...blockAttrs}>
|
|
523
|
+
<div class="bcms-tablist" role="tablist">
|
|
524
|
+
{arr(p.tabs).map((t: any, i: number) => (
|
|
525
|
+
<button
|
|
526
|
+
role="tab"
|
|
527
|
+
class="bcms-tab"
|
|
528
|
+
aria-selected={i === 0 ? "true" : "false"}
|
|
529
|
+
data-tab={String(i)}
|
|
530
|
+
{...bind(block.id, `tabs[${i}].label`, "text", bctx)}
|
|
531
|
+
>
|
|
532
|
+
{t?.label ?? ""}
|
|
533
|
+
</button>
|
|
534
|
+
))}
|
|
535
|
+
</div>
|
|
536
|
+
{arr(p.tabs).map((t: any, i: number) => (
|
|
537
|
+
<div role="tabpanel" class="bcms-tabpanel" data-panel={String(i)} hidden={i !== 0}>
|
|
538
|
+
<Astro.self blocks={arr(t?.children)} depth={depth} seen={seen} {...pass} />
|
|
539
|
+
</div>
|
|
540
|
+
))}
|
|
541
|
+
</div>
|
|
542
|
+
);
|
|
543
|
+
case "component": {
|
|
544
|
+
const resolved = resolveComponent(p);
|
|
545
|
+
if (!resolved) return null;
|
|
546
|
+
// 🔴 `fieldAddresses` AFTER `{...pass}`: the instance's own map must replace the
|
|
547
|
+
// enclosing one, not inherit it. Every stamp inside this subtree otherwise names a
|
|
548
|
+
// block belonging to the DEFINITION — shared by every instance of it, so it resolves
|
|
549
|
+
// to no field on one placement and collides into a dropped duplicate on two.
|
|
550
|
+
return (
|
|
551
|
+
<Astro.self
|
|
552
|
+
blocks={showScheduled ? resolved.blocks : visibleBlocks(resolved.blocks, at)}
|
|
553
|
+
depth={depth + 1}
|
|
554
|
+
seen={[...seen, resolved.id]}
|
|
555
|
+
containerAttrs={{
|
|
556
|
+
...blockAttrs,
|
|
557
|
+
"data-bcms-component": resolved.id,
|
|
558
|
+
"data-bcms-variant": "base",
|
|
559
|
+
"data-bcms-instance": block.id,
|
|
560
|
+
}}
|
|
561
|
+
{...pass}
|
|
562
|
+
fieldAddresses={owner === null ? new Map() : componentInstanceAddresses(block.id, resolved.props, owner)}
|
|
563
|
+
/>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
default:
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
})();
|
|
570
|
+
// FLO-264: honour the block's bounded style tokens here too, so Astro output matches
|
|
571
|
+
// the builder canvas, the Next adapter and the hosted site.
|
|
572
|
+
if (node == null) return null;
|
|
573
|
+
/**
|
|
574
|
+
* The style wrapper is the ONLY wrapper left, and it is unchanged: a style token needs a box
|
|
575
|
+
* to apply to. When it exists it IS this block's root, so it carries the marker and
|
|
576
|
+
* `blockAttrs` above is empty — the two are mutually exclusive by construction, so a block
|
|
577
|
+
* never gets the attribute twice and never gets a second <div>.
|
|
578
|
+
*
|
|
579
|
+
* Everything else now carries `data-bcms-block` on its own root (see `blockAttrs` above).
|
|
580
|
+
*/
|
|
581
|
+
/**
|
|
582
|
+
* The content column (`contentWidth`) is a SECOND box inside the style wrapper, as on the
|
|
583
|
+
* hosted lane (`applyBlockStyle`) and the Next adapter: the wrapper keeps the full-bleed
|
|
584
|
+
* background and only the inner box narrows. With a column and no other style, the column
|
|
585
|
+
* box is the root and carries the marker instead.
|
|
586
|
+
*/
|
|
587
|
+
const stamp = nested ? undefined : block.id;
|
|
588
|
+
if (style && inner) return <div style={style} data-bcms-hide={hide} data-bcms-block={stamp} data-bcms-window={scheduled}><div class="bcms-block-inner" style={inner}>{node}</div></div>;
|
|
589
|
+
if (hide) return <div style={style} data-bcms-hide={hide} data-bcms-block={stamp} data-bcms-window={scheduled}>{inner ? <div class="bcms-block-inner" style={inner}>{node}</div> : node}</div>;
|
|
590
|
+
if (inner) return <div class="bcms-block-inner" style={inner} data-bcms-block={stamp} data-bcms-window={scheduled}>{node}</div>;
|
|
591
|
+
if (style) return <div style={style} data-bcms-block={stamp} data-bcms-window={scheduled}>{node}</div>;
|
|
592
|
+
return node;
|
|
593
|
+
})}
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
{needsSlider && (
|
|
597
|
+
<script is:inline>
|
|
598
|
+
// Vanilla prev/next + optional autoplay, mirroring render-page.ts's SLIDER_SCRIPT.
|
|
599
|
+
// `bcmsSliderBound` makes binding idempotent: two top-level <BcmsBlocks> on one page
|
|
600
|
+
// each inline a copy, and every copy sees every slider (the same footgun that had
|
|
601
|
+
// <BcmsForm> submitting twice).
|
|
602
|
+
document.querySelectorAll("[data-bcms-slider]").forEach(function (root) {
|
|
603
|
+
if (root.dataset.bcmsSliderBound) return;
|
|
604
|
+
root.dataset.bcmsSliderBound = "1";
|
|
605
|
+
var slides = root.querySelectorAll(".bcms-slide");
|
|
606
|
+
if (!slides.length) return;
|
|
607
|
+
var i = 0;
|
|
608
|
+
function show(n) {
|
|
609
|
+
i = (n + slides.length) % slides.length;
|
|
610
|
+
slides.forEach(function (s, k) {
|
|
611
|
+
if (k === i) { s.setAttribute("data-active", ""); s.removeAttribute("hidden"); }
|
|
612
|
+
else { s.removeAttribute("data-active"); s.setAttribute("hidden", ""); }
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
var prev = root.querySelector("[data-prev]"), next = root.querySelector("[data-next]");
|
|
616
|
+
if (prev) prev.addEventListener("click", function () { show(i - 1); });
|
|
617
|
+
if (next) next.addEventListener("click", function () { show(i + 1); });
|
|
618
|
+
var ms = parseInt(root.getAttribute("data-autoplay") || "0", 10);
|
|
619
|
+
if (ms > 0) setInterval(function () { show(i + 1); }, ms);
|
|
620
|
+
});
|
|
621
|
+
</script>
|
|
622
|
+
)}
|
|
623
|
+
|
|
624
|
+
{needsTabs && (
|
|
625
|
+
<script is:inline>
|
|
626
|
+
// Toggles aria-selected + panel visibility, mirroring render-page.ts's TABS_SCRIPT.
|
|
627
|
+
// Idempotent for the same reason as the slider runtime above.
|
|
628
|
+
document.querySelectorAll("[data-bcms-tabs]").forEach(function (root) {
|
|
629
|
+
if (root.dataset.bcmsTabsBound) return;
|
|
630
|
+
root.dataset.bcmsTabsBound = "1";
|
|
631
|
+
var tabs = root.querySelectorAll("[role=tab]"), panels = root.querySelectorAll("[role=tabpanel]");
|
|
632
|
+
tabs.forEach(function (tab, idx) {
|
|
633
|
+
tab.addEventListener("click", function () {
|
|
634
|
+
tabs.forEach(function (t, k) { t.setAttribute("aria-selected", k === idx ? "true" : "false"); });
|
|
635
|
+
panels.forEach(function (pan, k) { if (k === idx) { pan.removeAttribute("hidden"); } else { pan.setAttribute("hidden", ""); } });
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
</script>
|
|
640
|
+
)}
|