@bettercms-ai/astro 0.8.2 → 0.8.3

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.
@@ -46,6 +46,10 @@ interface Props {
46
46
  /** Set on every self-recursion so only the outermost instance emits the runtime
47
47
  * scripts. Internal; do not pass. */
48
48
  nested?: boolean;
49
+ /** Attributes for the container <div> this component always emits. Used only by the
50
+ * `component` case, so a component instance's marker rides the container its own
51
+ * recursion already produces instead of gaining a second <div>. Internal; do not pass. */
52
+ containerAttrs?: Record<string, string | undefined>;
49
53
  class?: string;
50
54
  }
51
55
 
@@ -60,6 +64,7 @@ const {
60
64
  depth = 0,
61
65
  seen = [],
62
66
  nested = false,
67
+ containerAttrs = {},
63
68
  class: className,
64
69
  } = Astro.props;
65
70
  const formMap = new Map(forms.map((f) => [f.id, f]));
@@ -150,46 +155,68 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
150
155
  component recurses into itself for columns/tabs/sliders/components, so an HTML comment here
151
156
  shipped once per nesting level to every visitor (FLO-1161).
152
157
  */}
153
- <div class={className} data-bcms-page={nested ? undefined : ""}>
158
+ <div class={className} data-bcms-page={nested ? undefined : ""} {...containerAttrs}>
154
159
  {blocks.map((block) => {
155
160
  const p = anyProps(block);
156
161
  const style = styleAttr(block);
162
+ /**
163
+ * 🔴 `data-bcms-block` — the Visual Editor hit-tests this to find sections. It rides the
164
+ * block's OWN root element; it does NOT get a wrapper.
165
+ *
166
+ * A wrapper <div> is what used to be emitted here, and it was a block box around every
167
+ * top-level block. Two consecutive `button` blocks — whose `<a class="bcms-btn">` roots are
168
+ * `inline-flex` and are meant to sit side by side — were each sealed in their own block box
169
+ * and stacked. Measured on the live amor site: both wrappers `display:block`, width 1048,
170
+ * y=462 and y=540. The Next adapter never had this because React lets it `cloneElement`;
171
+ * Astro has no cloneElement, so the attribute is applied AT CONSTRUCTION instead — the same
172
+ * spread idiom this file already uses for the image binding below.
173
+ *
174
+ * `undefined` values are omitted by Astro, which is why `nested` blocks (an empty object
175
+ * here) emit nothing — a column's child must never look like a draggable section.
176
+ *
177
+ * When the block carries style tokens the wrapper below is its root, so the marker rides
178
+ * that instead and `bind` stays empty. Style tokens keep their own box: merging them onto
179
+ * the block's root changes behaviour (a spacer's padding is absorbed under `border-box`, a
180
+ * section's `padding` collides last-wins instead of adding, and a button's `text-align`
181
+ * would centre its label instead of the button).
182
+ */
183
+ const blockAttrs = nested || style ? {} : { "data-bcms-block": block.id };
157
184
  const node = (() => {
158
185
  switch (block.type) {
159
186
  case "heading": {
160
187
  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>;
188
+ return <Tag data-bcms-field={`${block.id}__text`} {...blockAttrs}>{p.text}</Tag>;
162
189
  }
163
190
  // `richtext` and `text` carry the same payload and differ only in the authoring
164
191
  // surface, so delivery renders them identically — the hosted renderer does the same.
165
192
  case "text":
166
193
  case "richtext":
167
- return <div class="bcms-text" data-bcms-field={`${block.id}__html`} data-bcms-kind="richtext" set:html={p.html ?? ""} />;
194
+ return <div class="bcms-text" data-bcms-field={`${block.id}__html`} data-bcms-kind="richtext" {...blockAttrs} set:html={p.html ?? ""} />;
168
195
  case "image": {
169
196
  // Matches the Next adapter: the image block is click-to-select on the Visual Editor
170
197
  // canvas like heading/text/button, rather than dock-only. `kind="image"` means select
171
198
  // + live src reflection, never contentEditable.
172
199
  const bind = { "data-bcms-field": `${block.id}__src`, "data-bcms-kind": "image" };
173
200
  return p.caption ? (
174
- <figure>
201
+ <figure {...blockAttrs}>
175
202
  <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...bind} />
176
203
  <figcaption>{p.caption}</figcaption>
177
204
  </figure>
178
205
  ) : (
179
- <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...bind} />
206
+ <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} {...bind} {...blockAttrs} />
180
207
  );
181
208
  }
182
209
  case "button":
183
210
  return (
184
- <a class={`bcms-btn bcms-btn-${p.variant === "secondary" ? "secondary" : "primary"}`} href={p.href ?? "#"} data-bcms-field={`${block.id}__text`}>
211
+ <a class={`bcms-btn bcms-btn-${p.variant === "secondary" ? "secondary" : "primary"}`} href={p.href ?? "#"} data-bcms-field={`${block.id}__text`} {...blockAttrs}>
185
212
  {p.text}
186
213
  </a>
187
214
  );
188
215
  case "spacer":
189
- return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} />;
216
+ return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} {...blockAttrs} />;
190
217
  case "video":
191
218
  return (
192
- <video src={p.url} poster={p.poster} controls autoplay={!!p.autoplay} muted={!!p.autoplay} loop={!!p.loop} playsinline />
219
+ <video src={p.url} poster={p.poster} controls autoplay={!!p.autoplay} muted={!!p.autoplay} loop={!!p.loop} playsinline {...blockAttrs} />
193
220
  );
194
221
  case "columns": {
195
222
  const cols: ContentBlock[][] = Array.isArray(p.columns) ? p.columns : [];
@@ -197,6 +224,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
197
224
  <div
198
225
  class="bcms-cols"
199
226
  style={`display:grid;grid-template-columns:repeat(${cols.length || 1},1fr);gap:${Number(p.gap) || 0}px`}
227
+ {...blockAttrs}
200
228
  >
201
229
  {cols.map((col) => (
202
230
  <div class="bcms-col">
@@ -208,7 +236,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
208
236
  }
209
237
  case "form": {
210
238
  const form = formMap.get(p.formId);
211
- return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} /> : null;
239
+ return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} {...blockAttrs} /> : null;
212
240
  }
213
241
  case "section": {
214
242
  const inner = <Astro.self blocks={arr(p.children)} depth={depth} seen={seen} {...pass} />;
@@ -220,6 +248,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
220
248
  p.background ? `background:${p.background}` : "",
221
249
  p.paddingY != null ? `padding:${Number(p.paddingY) || 0}px 0` : "",
222
250
  ].filter(Boolean).join(";") || undefined}
251
+ {...blockAttrs}
223
252
  >
224
253
  {mw ? <div class="bcms-section-inner" style={`max-width:${mw}px;margin:0 auto`}>{inner}</div> : inner}
225
254
  </section>
@@ -228,7 +257,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
228
257
  case "navbar": {
229
258
  const logo = p.logo && typeof p.logo === "object" ? p.logo : null;
230
259
  return (
231
- <nav class="bcms-navbar">
260
+ <nav class="bcms-navbar" {...blockAttrs}>
232
261
  {logo?.src ? (
233
262
  <a class="bcms-navbar-logo" href={logo.href ?? "/"}>
234
263
  <img src={logo.src} alt={logo.alt ?? ""} />
@@ -243,7 +272,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
243
272
  }
244
273
  case "footer":
245
274
  return (
246
- <footer class="bcms-footer">
275
+ <footer class="bcms-footer" {...blockAttrs}>
247
276
  <div class="bcms-footer-cols">
248
277
  {arr(p.columns).map((col: any) => (
249
278
  <div class="bcms-footer-col">
@@ -268,6 +297,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
268
297
  data-bcms-slider
269
298
  data-autoplay={p.autoplay ? String(Number(p.interval) || 5000) : undefined}
270
299
  data-loop={p.loop ? "" : undefined}
300
+ {...blockAttrs}
271
301
  >
272
302
  <div class="bcms-slides">
273
303
  {arr(p.slides).map((s: any, i: number) => (
@@ -282,7 +312,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
282
312
  );
283
313
  case "tabs":
284
314
  return (
285
- <div class="bcms-tabs" data-bcms-tabs>
315
+ <div class="bcms-tabs" data-bcms-tabs {...blockAttrs}>
286
316
  <div class="bcms-tablist" role="tablist">
287
317
  {arr(p.tabs).map((t: any, i: number) => (
288
318
  <button role="tab" class="bcms-tab" aria-selected={i === 0 ? "true" : "false"} data-tab={String(i)}>
@@ -300,7 +330,7 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
300
330
  case "component": {
301
331
  const resolved = resolveComponent(p);
302
332
  if (!resolved) return null;
303
- return <Astro.self blocks={resolved.blocks} depth={depth + 1} seen={[...seen, resolved.id]} {...pass} />;
333
+ return <Astro.self blocks={resolved.blocks} depth={depth + 1} seen={[...seen, resolved.id]} containerAttrs={blockAttrs} {...pass} />;
304
334
  }
305
335
  default:
306
336
  return null;
@@ -310,25 +340,14 @@ const pass = { forms, components, turnstileSiteKey, nested: true };
310
340
  // the builder canvas, the Next adapter and the hosted site.
311
341
  if (node == null) return null;
312
342
  /**
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.
343
+ * The style wrapper is the ONLY wrapper left, and it is unchanged: a style token needs a box
344
+ * to apply to. When it exists it IS this block's root, so it carries the marker and
345
+ * `blockAttrs` above is empty — the two are mutually exclusive by construction, so a block
346
+ * never gets the attribute twice and never gets a second <div>.
326
347
  *
327
- * When a style wrapper already exists the attribute rides it, so this never adds a SECOND div.
348
+ * Everything else now carries `data-bcms-block` on its own root (see `blockAttrs` above).
328
349
  */
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>;
350
+ if (style) return <div style={style} data-bcms-block={nested ? undefined : block.id}>{node}</div>;
332
351
  return node;
333
352
  })}
334
353
  </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.8.3",
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.10.0",
53
+ "@bettercms-ai/ui": "^0.6.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "astro": ">=4"