@bettercms-ai/astro 0.8.3 → 0.9.1

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";
@@ -50,6 +51,11 @@ interface Props {
50
51
  * `component` case, so a component instance's marker rides the container its own
51
52
  * recursion already produces instead of gaining a second <div>. Internal; do not pass. */
52
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>;
53
59
  class?: string;
54
60
  }
55
61
 
@@ -65,8 +71,11 @@ const {
65
71
  seen = [],
66
72
  nested = false,
67
73
  containerAttrs = {},
74
+ fieldAddresses,
68
75
  class: className,
69
76
  } = Astro.props;
77
+ /** What `bind`/`bindProps` read. One object, so every call site cannot disagree. */
78
+ const bctx = { fieldAddresses };
70
79
  const formMap = new Map(forms.map((f) => [f.id, f]));
71
80
  const componentMap = new Map(components.map((c) => [c.id, c]));
72
81
 
@@ -96,7 +105,9 @@ function styleAttr(block: ContentBlock): string | undefined {
96
105
  * template expression as JSX — `applyOverrides<ContentBlock>(…)` inline emitted a bogus
97
106
  * `<ContentBlock>` element and the file failed to compile at all.
98
107
  */
99
- 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 {
100
111
  const def = componentMap.get(String(p.componentId ?? ""));
101
112
  if (!def) return null;
102
113
  if (depth >= MAX_COMPONENT_DEPTH || seen.includes(def.id)) return null;
@@ -105,7 +116,9 @@ function resolveComponent(p: Record<string, any>): { id: string; blocks: Content
105
116
  def.props ?? [],
106
117
  (p.overrides as Record<string, unknown>) ?? {},
107
118
  );
108
- 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 ?? [] };
109
122
  }
110
123
 
111
124
  /**
@@ -139,7 +152,10 @@ const needsSlider = !nested && containsType(blocks, "slider");
139
152
  const needsTabs = !nested && containsType(blocks, "tabs");
140
153
 
141
154
  /** Props every self-recursion forwards unchanged. */
142
- 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 };
143
159
  ---
144
160
 
145
161
  {/*
@@ -185,30 +201,41 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
185
201
  switch (block.type) {
186
202
  case "heading": {
187
203
  const Tag = `h${clamp(Number(p.level) || 2, 1, 6)}` as keyof HTMLElementTagNameMap;
188
- return <Tag data-bcms-field={`${block.id}__text`} {...blockAttrs}>{p.text}</Tag>;
204
+ return <Tag {...bind(block.id, "text", "text", bctx)} {...blockAttrs}>{p.text}</Tag>;
189
205
  }
190
206
  // `richtext` and `text` carry the same payload and differ only in the authoring
191
207
  // surface, so delivery renders them identically — the hosted renderer does the same.
192
208
  case "text":
193
209
  case "richtext":
194
- return <div class="bcms-text" data-bcms-field={`${block.id}__html`} data-bcms-kind="richtext" {...blockAttrs} set:html={p.html ?? ""} />;
210
+ return <div class="bcms-text" {...bind(block.id, "html", "richtext", bctx)} {...blockAttrs} set:html={p.html ?? ""} />;
195
211
  case "image": {
196
212
  // Matches the Next adapter: the image block is click-to-select on the Visual Editor
197
213
  // canvas like heading/text/button, rather than dock-only. `kind="image"` means select
198
214
  // + live src reflection, never contentEditable.
199
- 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
+ };
200
221
  return p.caption ? (
201
222
  <figure {...blockAttrs}>
202
- <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...bind} />
203
- <figcaption>{p.caption}</figcaption>
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>
204
225
  </figure>
205
226
  ) : (
206
- <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...bind} {...blockAttrs} />
227
+ <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...imgBind} {...blockAttrs} />
207
228
  );
208
229
  }
209
230
  case "button":
210
231
  return (
211
- <a class={`bcms-btn bcms-btn-${p.variant === "secondary" ? "secondary" : "primary"}`} href={p.href ?? "#"} data-bcms-field={`${block.id}__text`} {...blockAttrs}>
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
+ >
212
239
  {p.text}
213
240
  </a>
214
241
  );
@@ -216,7 +243,24 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
216
243
  return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} {...blockAttrs} />;
217
244
  case "video":
218
245
  return (
219
- <video src={p.url} poster={p.poster} controls autoplay={!!p.autoplay} muted={!!p.autoplay} loop={!!p.loop} playsinline {...blockAttrs} />
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
+ />
220
264
  );
221
265
  case "columns": {
222
266
  const cols: ContentBlock[][] = Array.isArray(p.columns) ? p.columns : [];
@@ -260,13 +304,37 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
260
304
  <nav class="bcms-navbar" {...blockAttrs}>
261
305
  {logo?.src ? (
262
306
  <a class="bcms-navbar-logo" href={logo.href ?? "/"}>
263
- <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
+ />
264
313
  </a>
265
314
  ) : null}
266
315
  <div class="bcms-navbar-links">
267
- {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
+ ))}
268
327
  </div>
269
- {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}
270
338
  </nav>
271
339
  );
272
340
  }
@@ -274,16 +342,27 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
274
342
  return (
275
343
  <footer class="bcms-footer" {...blockAttrs}>
276
344
  <div class="bcms-footer-cols">
277
- {arr(p.columns).map((col: any) => (
345
+ {arr(p.columns).map((col: any, i: number) => (
278
346
  <div class="bcms-footer-col">
279
- {col?.heading ? <h4>{col.heading}</h4> : null}
347
+ {col?.heading ? <h4 {...bind(block.id, `columns[${i}].heading`, "text", bctx)}>{col.heading}</h4> : null}
280
348
  <ul>
281
- {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
+ ))}
282
361
  </ul>
283
362
  </div>
284
363
  ))}
285
364
  </div>
286
- {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}
287
366
  </footer>
288
367
  );
289
368
  case "slider":
@@ -315,7 +394,13 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
315
394
  <div class="bcms-tabs" data-bcms-tabs {...blockAttrs}>
316
395
  <div class="bcms-tablist" role="tablist">
317
396
  {arr(p.tabs).map((t: any, i: number) => (
318
- <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
+ >
319
404
  {t?.label ?? ""}
320
405
  </button>
321
406
  ))}
@@ -330,7 +415,20 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
330
415
  case "component": {
331
416
  const resolved = resolveComponent(p);
332
417
  if (!resolved) return null;
333
- return <Astro.self blocks={resolved.blocks} depth={depth + 1} seen={[...seen, resolved.id]} containerAttrs={blockAttrs} {...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
+ );
334
432
  }
335
433
  default:
336
434
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/astro",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
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",
@@ -47,9 +47,9 @@
47
47
  "test:watch": "vitest"
48
48
  },
49
49
  "dependencies": {
50
- "@bettercms-ai/image-url": "^0.2.0",
51
- "@bettercms-ai/sdk": "^1.13.0",
52
- "@bettercms-ai/types": "^1.10.0",
50
+ "@bettercms-ai/image-url": "^0.3.0",
51
+ "@bettercms-ai/sdk": "^1.13.1",
52
+ "@bettercms-ai/types": "^1.11.0",
53
53
  "@bettercms-ai/ui": "^0.6.0"
54
54
  },
55
55
  "peerDependencies": {