@bettercms-ai/astro 0.8.2 → 0.9.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.
@@ -26,7 +26,8 @@
26
26
  * actually contains that block (mirrors render-page.ts's `ctx.flags` gating).
27
27
  */
28
28
  import type { ContentBlock } from "@bettercms-ai/types";
29
- import { blockStyleToCss } from "@bettercms-ai/types";
29
+ import { blockStyleToCss, componentInstanceAddresses } from "@bettercms-ai/types";
30
+ import { bind, bindProps } from "../src/bindings.js";
30
31
  import type { DeliveryComponent, DeliveryForm } from "@bettercms-ai/sdk";
31
32
  import { applyOverrides } from "@bettercms-ai/ui/resolve";
32
33
  import BcmsImage from "./BcmsImage.astro";
@@ -46,6 +47,15 @@ interface Props {
46
47
  /** Set on every self-recursion so only the outermost instance emits the runtime
47
48
  * scripts. Internal; do not pass. */
48
49
  nested?: boolean;
50
+ /** Attributes for the container <div> this component always emits. Used only by the
51
+ * `component` case, so a component instance's marker rides the container its own
52
+ * recursion already produces instead of gaining a second <div>. Internal; do not pass. */
53
+ containerAttrs?: Record<string, string | undefined>;
54
+ /** Inside a component instance, the DEFINITION's `<blockId>__<prop>` mapped to this
55
+ * instance's `<id>__overrides.<key>`. AUTHORITATIVE where present: a prop the definition
56
+ * does not declare gets no address, rather than one that binds to no field.
57
+ * Internal; do not pass. */
58
+ fieldAddresses?: Map<string, string>;
49
59
  class?: string;
50
60
  }
51
61
 
@@ -60,8 +70,12 @@ const {
60
70
  depth = 0,
61
71
  seen = [],
62
72
  nested = false,
73
+ containerAttrs = {},
74
+ fieldAddresses,
63
75
  class: className,
64
76
  } = Astro.props;
77
+ /** What `bind`/`bindProps` read. One object, so every call site cannot disagree. */
78
+ const bctx = { fieldAddresses };
65
79
  const formMap = new Map(forms.map((f) => [f.id, f]));
66
80
  const componentMap = new Map(components.map((c) => [c.id, c]));
67
81
 
@@ -91,7 +105,9 @@ function styleAttr(block: ContentBlock): string | undefined {
91
105
  * template expression as JSX — `applyOverrides<ContentBlock>(…)` inline emitted a bogus
92
106
  * `<ContentBlock>` element and the file failed to compile at all.
93
107
  */
94
- function resolveComponent(p: Record<string, any>): { id: string; blocks: ContentBlock[] } | null {
108
+ function resolveComponent(
109
+ p: Record<string, any>,
110
+ ): { id: string; blocks: ContentBlock[]; props: DeliveryComponent["props"] } | null {
95
111
  const def = componentMap.get(String(p.componentId ?? ""));
96
112
  if (!def) return null;
97
113
  if (depth >= MAX_COMPONENT_DEPTH || seen.includes(def.id)) return null;
@@ -100,7 +116,9 @@ function resolveComponent(p: Record<string, any>): { id: string; blocks: Content
100
116
  def.props ?? [],
101
117
  (p.overrides as Record<string, unknown>) ?? {},
102
118
  );
103
- return { id: def.id, blocks: resolved };
119
+ // `props` comes back too: the caller needs the DECLARATION (which block each prop drives) to
120
+ // build this instance's addresses, not just the resolved tree.
121
+ return { id: def.id, blocks: resolved, props: def.props ?? [] };
104
122
  }
105
123
 
106
124
  /**
@@ -134,7 +152,10 @@ const needsSlider = !nested && containsType(blocks, "slider");
134
152
  const needsTabs = !nested && containsType(blocks, "tabs");
135
153
 
136
154
  /** Props every self-recursion forwards unchanged. */
137
- const pass = { forms, components, turnstileSiteKey, nested: true };
155
+ // `fieldAddresses` rides along: a slide or tab panel is still inside the SAME component
156
+ // instance, so its children keep that instance's addresses. The `component` case below
157
+ // overrides it with the NEW instance's map.
158
+ const pass = { forms, components, turnstileSiteKey, nested: true, fieldAddresses };
138
159
  ---
139
160
 
140
161
  {/*
@@ -150,46 +171,96 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
150
171
  component recurses into itself for columns/tabs/sliders/components, so an HTML comment here
151
172
  shipped once per nesting level to every visitor (FLO-1161).
152
173
  */}
153
- <div class={className} data-bcms-page={nested ? undefined : ""}>
174
+ <div class={className} data-bcms-page={nested ? undefined : ""} {...containerAttrs}>
154
175
  {blocks.map((block) => {
155
176
  const p = anyProps(block);
156
177
  const style = styleAttr(block);
178
+ /**
179
+ * 🔴 `data-bcms-block` — the Visual Editor hit-tests this to find sections. It rides the
180
+ * block's OWN root element; it does NOT get a wrapper.
181
+ *
182
+ * A wrapper <div> is what used to be emitted here, and it was a block box around every
183
+ * top-level block. Two consecutive `button` blocks — whose `<a class="bcms-btn">` roots are
184
+ * `inline-flex` and are meant to sit side by side — were each sealed in their own block box
185
+ * and stacked. Measured on the live amor site: both wrappers `display:block`, width 1048,
186
+ * y=462 and y=540. The Next adapter never had this because React lets it `cloneElement`;
187
+ * Astro has no cloneElement, so the attribute is applied AT CONSTRUCTION instead — the same
188
+ * spread idiom this file already uses for the image binding below.
189
+ *
190
+ * `undefined` values are omitted by Astro, which is why `nested` blocks (an empty object
191
+ * here) emit nothing — a column's child must never look like a draggable section.
192
+ *
193
+ * When the block carries style tokens the wrapper below is its root, so the marker rides
194
+ * that instead and `bind` stays empty. Style tokens keep their own box: merging them onto
195
+ * the block's root changes behaviour (a spacer's padding is absorbed under `border-box`, a
196
+ * section's `padding` collides last-wins instead of adding, and a button's `text-align`
197
+ * would centre its label instead of the button).
198
+ */
199
+ const blockAttrs = nested || style ? {} : { "data-bcms-block": block.id };
157
200
  const node = (() => {
158
201
  switch (block.type) {
159
202
  case "heading": {
160
203
  const Tag = `h${clamp(Number(p.level) || 2, 1, 6)}` as keyof HTMLElementTagNameMap;
161
- return <Tag data-bcms-field={`${block.id}__text`}>{p.text}</Tag>;
204
+ return <Tag {...bind(block.id, "text", "text", bctx)} {...blockAttrs}>{p.text}</Tag>;
162
205
  }
163
206
  // `richtext` and `text` carry the same payload and differ only in the authoring
164
207
  // surface, so delivery renders them identically — the hosted renderer does the same.
165
208
  case "text":
166
209
  case "richtext":
167
- return <div class="bcms-text" data-bcms-field={`${block.id}__html`} data-bcms-kind="richtext" set:html={p.html ?? ""} />;
210
+ return <div class="bcms-text" {...bind(block.id, "html", "richtext", bctx)} {...blockAttrs} set:html={p.html ?? ""} />;
168
211
  case "image": {
169
212
  // Matches the Next adapter: the image block is click-to-select on the Visual Editor
170
213
  // canvas like heading/text/button, rather than dock-only. `kind="image"` means select
171
214
  // + live src reflection, never contentEditable.
172
- const bind = { "data-bcms-field": `${block.id}__src`, "data-bcms-kind": "image" };
215
+ // `alt` rides the props lane: this element already carries the src binding and one
216
+ // element cannot hold two addresses.
217
+ const imgBind = {
218
+ ...bind(block.id, "src", "image", bctx),
219
+ ...bindProps(block.id, [{ prop: "alt", kind: "text", attr: "alt", present: p.alt != null }], bctx),
220
+ };
173
221
  return p.caption ? (
174
- <figure>
175
- <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...bind} />
176
- <figcaption>{p.caption}</figcaption>
222
+ <figure {...blockAttrs}>
223
+ <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...imgBind} />
224
+ <figcaption {...bind(block.id, "caption", "text", bctx)}>{p.caption}</figcaption>
177
225
  </figure>
178
226
  ) : (
179
- <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...bind} />
227
+ <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...imgBind} {...blockAttrs} />
180
228
  );
181
229
  }
182
230
  case "button":
183
231
  return (
184
- <a class={`bcms-btn bcms-btn-${p.variant === "secondary" ? "secondary" : "primary"}`} href={p.href ?? "#"} data-bcms-field={`${block.id}__text`}>
232
+ <a
233
+ class={`bcms-btn bcms-btn-${p.variant === "secondary" ? "secondary" : "primary"}`}
234
+ href={p.href ?? "#"}
235
+ {...bind(block.id, "text", "text", bctx)}
236
+ {...bindProps(block.id, [{ prop: "href", kind: "url", attr: "href", present: p.href != null }], bctx)}
237
+ {...blockAttrs}
238
+ >
185
239
  {p.text}
186
240
  </a>
187
241
  );
188
242
  case "spacer":
189
- return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} />;
243
+ return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} {...blockAttrs} />;
190
244
  case "video":
191
245
  return (
192
- <video src={p.url} poster={p.poster} controls autoplay={!!p.autoplay} muted={!!p.autoplay} loop={!!p.loop} playsinline />
246
+ <video
247
+ src={p.url}
248
+ poster={p.poster}
249
+ controls
250
+ autoplay={!!p.autoplay}
251
+ muted={!!p.autoplay}
252
+ loop={!!p.loop}
253
+ playsinline
254
+ {...bindProps(
255
+ block.id,
256
+ [
257
+ { prop: "url", kind: "url", attr: "src", present: p.url != null },
258
+ { prop: "poster", kind: "url", attr: "poster", present: p.poster != null },
259
+ ],
260
+ bctx,
261
+ )}
262
+ {...blockAttrs}
263
+ />
193
264
  );
194
265
  case "columns": {
195
266
  const cols: ContentBlock[][] = Array.isArray(p.columns) ? p.columns : [];
@@ -197,6 +268,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
197
268
  <div
198
269
  class="bcms-cols"
199
270
  style={`display:grid;grid-template-columns:repeat(${cols.length || 1},1fr);gap:${Number(p.gap) || 0}px`}
271
+ {...blockAttrs}
200
272
  >
201
273
  {cols.map((col) => (
202
274
  <div class="bcms-col">
@@ -208,7 +280,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
208
280
  }
209
281
  case "form": {
210
282
  const form = formMap.get(p.formId);
211
- return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} /> : null;
283
+ return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} {...blockAttrs} /> : null;
212
284
  }
213
285
  case "section": {
214
286
  const inner = <Astro.self blocks={arr(p.children)} depth={depth} seen={seen} {...pass} />;
@@ -220,6 +292,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
220
292
  p.background ? `background:${p.background}` : "",
221
293
  p.paddingY != null ? `padding:${Number(p.paddingY) || 0}px 0` : "",
222
294
  ].filter(Boolean).join(";") || undefined}
295
+ {...blockAttrs}
223
296
  >
224
297
  {mw ? <div class="bcms-section-inner" style={`max-width:${mw}px;margin:0 auto`}>{inner}</div> : inner}
225
298
  </section>
@@ -228,33 +301,68 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
228
301
  case "navbar": {
229
302
  const logo = p.logo && typeof p.logo === "object" ? p.logo : null;
230
303
  return (
231
- <nav class="bcms-navbar">
304
+ <nav class="bcms-navbar" {...blockAttrs}>
232
305
  {logo?.src ? (
233
306
  <a class="bcms-navbar-logo" href={logo.href ?? "/"}>
234
- <img src={logo.src} alt={logo.alt ?? ""} />
307
+ <img
308
+ src={logo.src}
309
+ alt={logo.alt ?? ""}
310
+ {...bind(block.id, "logo.src", "image", bctx)}
311
+ {...bindProps(block.id, [{ prop: "logo.alt", kind: "text", attr: "alt", present: logo.alt != null }], bctx)}
312
+ />
235
313
  </a>
236
314
  ) : null}
237
315
  <div class="bcms-navbar-links">
238
- {arr(p.links).map((l: any) => <a href={l?.href ?? "#"}>{l?.label ?? ""}</a>)}
316
+ {/* Each link addressed by index. The old "nested under arrays" exclusion was
317
+ about a writer ceiling that no longer exists: setFieldPath walks N indices. */}
318
+ {arr(p.links).map((l: any, i: number) => (
319
+ <a
320
+ href={l?.href ?? "#"}
321
+ {...bind(block.id, `links[${i}].label`, "text", bctx)}
322
+ {...bindProps(block.id, [{ prop: `links[${i}].href`, kind: "url", attr: "href", present: l?.href != null }], bctx)}
323
+ >
324
+ {l?.label ?? ""}
325
+ </a>
326
+ ))}
239
327
  </div>
240
- {p.cta?.text ? <a class="bcms-btn bcms-btn-primary" href={p.cta.href ?? "#"}>{p.cta.text}</a> : null}
328
+ {p.cta?.text ? (
329
+ <a
330
+ class="bcms-btn bcms-btn-primary"
331
+ href={p.cta.href ?? "#"}
332
+ {...bind(block.id, "cta.text", "text", bctx)}
333
+ {...bindProps(block.id, [{ prop: "cta.href", kind: "url", attr: "href", present: p.cta.href != null }], bctx)}
334
+ >
335
+ {p.cta.text}
336
+ </a>
337
+ ) : null}
241
338
  </nav>
242
339
  );
243
340
  }
244
341
  case "footer":
245
342
  return (
246
- <footer class="bcms-footer">
343
+ <footer class="bcms-footer" {...blockAttrs}>
247
344
  <div class="bcms-footer-cols">
248
- {arr(p.columns).map((col: any) => (
345
+ {arr(p.columns).map((col: any, i: number) => (
249
346
  <div class="bcms-footer-col">
250
- {col?.heading ? <h4>{col.heading}</h4> : null}
347
+ {col?.heading ? <h4 {...bind(block.id, `columns[${i}].heading`, "text", bctx)}>{col.heading}</h4> : null}
251
348
  <ul>
252
- {arr(col?.links).map((l: any) => <li><a href={l?.href ?? "#"}>{l?.label ?? ""}</a></li>)}
349
+ {/* Two indices deep — the deepest addressable thing on a page. */}
350
+ {arr(col?.links).map((l: any, j: number) => (
351
+ <li>
352
+ <a
353
+ href={l?.href ?? "#"}
354
+ {...bind(block.id, `columns[${i}].links[${j}].label`, "text", bctx)}
355
+ {...bindProps(block.id, [{ prop: `columns[${i}].links[${j}].href`, kind: "url", attr: "href", present: l?.href != null }], bctx)}
356
+ >
357
+ {l?.label ?? ""}
358
+ </a>
359
+ </li>
360
+ ))}
253
361
  </ul>
254
362
  </div>
255
363
  ))}
256
364
  </div>
257
- {p.copyright ? <p class="bcms-footer-copy">{p.copyright}</p> : null}
365
+ {p.copyright ? <p class="bcms-footer-copy" {...bind(block.id, "copyright", "text", bctx)}>{p.copyright}</p> : null}
258
366
  </footer>
259
367
  );
260
368
  case "slider":
@@ -268,6 +376,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
268
376
  data-bcms-slider
269
377
  data-autoplay={p.autoplay ? String(Number(p.interval) || 5000) : undefined}
270
378
  data-loop={p.loop ? "" : undefined}
379
+ {...blockAttrs}
271
380
  >
272
381
  <div class="bcms-slides">
273
382
  {arr(p.slides).map((s: any, i: number) => (
@@ -282,10 +391,16 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
282
391
  );
283
392
  case "tabs":
284
393
  return (
285
- <div class="bcms-tabs" data-bcms-tabs>
394
+ <div class="bcms-tabs" data-bcms-tabs {...blockAttrs}>
286
395
  <div class="bcms-tablist" role="tablist">
287
396
  {arr(p.tabs).map((t: any, i: number) => (
288
- <button role="tab" class="bcms-tab" aria-selected={i === 0 ? "true" : "false"} data-tab={String(i)}>
397
+ <button
398
+ role="tab"
399
+ class="bcms-tab"
400
+ aria-selected={i === 0 ? "true" : "false"}
401
+ data-tab={String(i)}
402
+ {...bind(block.id, `tabs[${i}].label`, "text", bctx)}
403
+ >
289
404
  {t?.label ?? ""}
290
405
  </button>
291
406
  ))}
@@ -300,7 +415,20 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
300
415
  case "component": {
301
416
  const resolved = resolveComponent(p);
302
417
  if (!resolved) return null;
303
- return <Astro.self blocks={resolved.blocks} depth={depth + 1} seen={[...seen, resolved.id]} {...pass} />;
418
+ // 🔴 `fieldAddresses` AFTER `{...pass}`: the instance's own map must replace the
419
+ // enclosing one, not inherit it. Every stamp inside this subtree otherwise names a
420
+ // block belonging to the DEFINITION — shared by every instance of it, so it resolves
421
+ // to no field on one placement and collides into a dropped duplicate on two.
422
+ return (
423
+ <Astro.self
424
+ blocks={resolved.blocks}
425
+ depth={depth + 1}
426
+ seen={[...seen, resolved.id]}
427
+ containerAttrs={blockAttrs}
428
+ {...pass}
429
+ fieldAddresses={componentInstanceAddresses(block.id, resolved.props)}
430
+ />
431
+ );
304
432
  }
305
433
  default:
306
434
  return null;
@@ -310,25 +438,14 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
310
438
  // the builder canvas, the Next adapter and the hosted site.
311
439
  if (node == null) return null;
312
440
  /**
313
- * 🔴 `data-bcms-block` — the Visual Editor hit-tests this to find sections. Without it the
314
- * editor renders every block and recognises none of them, so move / duplicate / delete /
315
- * reorder and per-section presence never appear on an Astro site. Field bindings alone
316
- * (`data-bcms-field`) make text editable and tell the editor nothing about structure.
317
- *
318
- * TOP LEVEL ONLY (`!nested`): a section IS a top-level block, and tagging nested ones would
319
- * make a column's child look like a section you could drag out of its parent.
320
- *
321
- * A wrapper element rather than an attribute on `node`, because Astro renders to HTML and has
322
- * no `cloneElement` — the same constraint that made the hosted renderer wrap
323
- * (`render-page.ts`), and the same reasoning applies: a plain block-level div, so a full-bleed
324
- * section background still reaches the edges. The Next adapter clones instead, precisely
325
- * because React lets it and a wrapper there would land inside the customer's own layout.
441
+ * The style wrapper is the ONLY wrapper left, and it is unchanged: a style token needs a box
442
+ * to apply to. When it exists it IS this block's root, so it carries the marker and
443
+ * `blockAttrs` above is empty — the two are mutually exclusive by construction, so a block
444
+ * never gets the attribute twice and never gets a second <div>.
326
445
  *
327
- * When a style wrapper already exists the attribute rides it, so this never adds a SECOND div.
446
+ * Everything else now carries `data-bcms-block` on its own root (see `blockAttrs` above).
328
447
  */
329
- const blockId = nested ? undefined : block.id;
330
- if (style) return <div style={style} data-bcms-block={blockId}>{node}</div>;
331
- if (blockId) return <div data-bcms-block={blockId}>{node}</div>;
448
+ if (style) return <div style={style} data-bcms-block={nested ? undefined : block.id}>{node}</div>;
332
449
  return node;
333
450
  })}
334
451
  </div>
@@ -14,9 +14,14 @@ interface Props {
14
14
  form: DeliveryForm;
15
15
  turnstileSiteKey?: string | null;
16
16
  class?: string;
17
+ /** Anything else lands on the <form> itself. <BcmsBlocks> uses this to put a form block's
18
+ * `data-bcms-block` marker on its own root — without it the attribute is passed as a prop
19
+ * nothing reads, the marker silently vanishes, and a top-level form becomes a section the
20
+ * Visual Editor cannot see. Caught by the marker canary in blocks.test.ts. */
21
+ [key: `data-${string}`]: unknown;
17
22
  }
18
23
 
19
- const { form, turnstileSiteKey, class: className } = Astro.props;
24
+ const { form, turnstileSiteKey, class: className, ...rest } = Astro.props;
20
25
  const action = `${config.apiUrl}/api/v1/forms/public/${encodeURIComponent(form.id)}/submissions`;
21
26
 
22
27
  const INPUT_TYPE: Partial<Record<DeliveryFormField["type"], string>> = {
@@ -37,6 +42,7 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
37
42
  data-bcms-form={form.id}
38
43
  data-bcms-form-success={form.successMessage ?? "Thanks!"}
39
44
  data-bcms-form-redirect={form.redirectUrl ?? ""}
45
+ {...rest}
40
46
  >
41
47
  {form.fields?.map((f) => {
42
48
  const wrapAttrs = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/astro",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "The BetterCMS adapter for Astro — a `bettercms()` integration, a `bettercms:client` virtual module, typed content loaders, draft preview, and native .astro rendering components.",
6
6
  "main": "./dist/index.js",
@@ -48,9 +48,9 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@bettercms-ai/image-url": "^0.2.0",
51
- "@bettercms-ai/sdk": "^1.12.1",
52
- "@bettercms-ai/types": "^1.8.0",
53
- "@bettercms-ai/ui": "^0.4.0"
51
+ "@bettercms-ai/sdk": "^1.13.0",
52
+ "@bettercms-ai/types": "^1.11.0",
53
+ "@bettercms-ai/ui": "^0.6.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "astro": ">=4"