@bettercms-ai/astro 0.4.0 → 0.6.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 CHANGED
@@ -109,7 +109,7 @@ or on-demand-revalidation hook.
109
109
 
110
110
  | Component | Purpose |
111
111
  | --- | --- |
112
- | `@bettercms-ai/astro/components/BcmsBlocks.astro` | Render a page's `blockJson` (all 8 block types). |
112
+ | `@bettercms-ai/astro/components/BcmsBlocks.astro` | Render a page's `blockJson` — every block type the builder can author (heading, text/richtext, image, button, spacer, video, columns, form, section, navbar, footer, slider, tabs, component), with block style tokens applied. |
113
113
  | `@bettercms-ai/astro/components/BcmsForm.astro` | Render + submit a form (conditional fields, honeypot, Turnstile). |
114
114
  | `@bettercms-ai/astro/components/BcmsImage.astro` | Optimized image with a 1x/2x srcset via the media transform endpoint. |
115
115
 
@@ -1,38 +1,156 @@
1
1
  ---
2
2
  /**
3
- * <BcmsBlocks> — renders a page's blockJson tree natively in Astro. Covers all
4
- * 8 block types (heading, text, image, button, spacer, video, columns, form),
5
- * with `columns` recursing via Astro.self and `form` resolving from the `forms`
6
- * prop. Class names mirror the server-side renderer.
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).
7
27
  */
8
28
  import type { ContentBlock } from "@bettercms-ai/types";
9
- import type { DeliveryForm } from "@bettercms-ai/sdk";
29
+ import { blockStyleToCss } from "@bettercms-ai/types";
30
+ import type { DeliveryComponent, DeliveryForm } from "@bettercms-ai/sdk";
31
+ import { applyOverrides } from "@bettercms-ai/ui/resolve";
10
32
  import BcmsImage from "./BcmsImage.astro";
11
33
  import BcmsForm from "./BcmsForm.astro";
12
34
 
13
35
  interface Props {
14
36
  blocks: ContentBlock[];
15
37
  forms?: DeliveryForm[];
38
+ /** Definitions for `component` blocks — pass `readComponents()` from @bettercms-ai/next
39
+ * or fetch `/delivery/:workspace/components`. Absent ⇒ instances render nothing. */
40
+ components?: DeliveryComponent[];
16
41
  turnstileSiteKey?: string | null;
42
+ /** Recursion depth for nested components. Internal; do not pass. */
43
+ depth?: number;
44
+ /** Component ids already on this branch — cycle guard. Internal; do not pass. */
45
+ seen?: string[];
46
+ /** Set on every self-recursion so only the outermost instance emits the runtime
47
+ * scripts. Internal; do not pass. */
48
+ nested?: boolean;
17
49
  class?: string;
18
50
  }
19
51
 
20
- const { blocks = [], forms = [], turnstileSiteKey, class: className } = Astro.props;
52
+ /** Matches render-page.ts and @bettercms-ai/next; a component tree deeper than this is a bug. */
53
+ const MAX_COMPONENT_DEPTH = 10;
54
+
55
+ const {
56
+ blocks = [],
57
+ forms = [],
58
+ components = [],
59
+ turnstileSiteKey,
60
+ depth = 0,
61
+ seen = [],
62
+ nested = false,
63
+ class: className,
64
+ } = Astro.props;
21
65
  const formMap = new Map(forms.map((f) => [f.id, f]));
66
+ const componentMap = new Map(components.map((c) => [c.id, c]));
22
67
 
23
68
  const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
24
69
  const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).props ?? {};
70
+ const arr = (v: unknown): any[] => (Array.isArray(v) ? v : []);
71
+
72
+ /**
73
+ * `blockStyleToCss` returns a React-style declaration object (camelCase keys); Astro's
74
+ * `style` attribute wants a string. Same token set in ⇒ same pixels out as Next.
75
+ */
76
+ function styleAttr(block: ContentBlock): string | undefined {
77
+ const css = blockStyleToCss((block as { style?: any }).style);
78
+ if (!css) return undefined;
79
+ return Object.entries(css)
80
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}:${v}`)
81
+ .join(";");
82
+ }
83
+
84
+ /**
85
+ * Resolve a `component` block to the blocks it stands for. Returns null to fail soft on a
86
+ * deleted/unknown definition (exactly like the server renderer — a dangling reference must
87
+ * never break the build), and on the depth/cycle guard: a component's blockJson can embed
88
+ * other components and nothing validates against a cycle at write time.
89
+ *
90
+ * Lives in the frontmatter, not the template, because the Astro compiler parses `<T>` in a
91
+ * template expression as JSX — `applyOverrides<ContentBlock>(…)` inline emitted a bogus
92
+ * `<ContentBlock>` element and the file failed to compile at all.
93
+ */
94
+ function resolveComponent(p: Record<string, any>): { id: string; blocks: ContentBlock[] } | null {
95
+ const def = componentMap.get(String(p.componentId ?? ""));
96
+ if (!def) return null;
97
+ if (depth >= MAX_COMPONENT_DEPTH || seen.includes(def.id)) return null;
98
+ const resolved = applyOverrides<ContentBlock>(
99
+ def.blockJson,
100
+ def.props ?? [],
101
+ (p.overrides as Record<string, unknown>) ?? {},
102
+ );
103
+ return { id: def.id, blocks: resolved };
104
+ }
105
+
106
+ /**
107
+ * Depth-limited scan for a block type anywhere in the tree, components included, so the
108
+ * slider/tabs runtimes only ship on pages that use them — the same gating render-page.ts
109
+ * does with `ctx.flags`.
110
+ */
111
+ function containsType(list: unknown, type: string, d = 0, chain: string[] = []): boolean {
112
+ if (d > MAX_COMPONENT_DEPTH) return false;
113
+ for (const b of arr(list)) {
114
+ if (!b || typeof b !== "object") continue;
115
+ if (b.type === type) return true;
116
+ const p = b.props ?? {};
117
+ if (b.type === "component") {
118
+ const def = componentMap.get(String(p.componentId ?? ""));
119
+ if (def && !chain.includes(def.id) && containsType(def.blockJson, type, d + 1, [...chain, def.id])) return true;
120
+ continue;
121
+ }
122
+ const kids = [
123
+ ...arr(p.children),
124
+ ...arr(p.columns).flatMap((c: unknown) => arr(c)),
125
+ ...arr(p.slides).flatMap((s: any) => arr(s?.children)),
126
+ ...arr(p.tabs).flatMap((t: any) => arr(t?.children)),
127
+ ];
128
+ if (containsType(kids, type, d, chain)) return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ const needsSlider = !nested && containsType(blocks, "slider");
134
+ const needsTabs = !nested && containsType(blocks, "tabs");
135
+
136
+ /** Props every self-recursion forwards unchanged. */
137
+ const pass = { forms, components, turnstileSiteKey, nested: true };
25
138
  ---
26
139
 
27
140
  <div class={className}>
28
141
  {blocks.map((block) => {
29
142
  const p = anyProps(block);
143
+ const style = styleAttr(block);
144
+ const node = (() => {
30
145
  switch (block.type) {
31
146
  case "heading": {
32
147
  const Tag = `h${clamp(Number(p.level) || 2, 1, 6)}` as keyof HTMLElementTagNameMap;
33
148
  return <Tag data-bcms-field={`${block.id}__text`}>{p.text}</Tag>;
34
149
  }
150
+ // `richtext` and `text` carry the same payload and differ only in the authoring
151
+ // surface, so delivery renders them identically — the hosted renderer does the same.
35
152
  case "text":
153
+ case "richtext":
36
154
  return <div class="bcms-text" data-bcms-field={`${block.id}__html`} data-bcms-kind="richtext" set:html={p.html ?? ""} />;
37
155
  case "image": {
38
156
  // Matches the Next adapter: the image block is click-to-select on the Visual Editor
@@ -69,7 +187,7 @@ const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).pro
69
187
  >
70
188
  {cols.map((col) => (
71
189
  <div class="bcms-col">
72
- <Astro.self blocks={col} forms={forms} turnstileSiteKey={turnstileSiteKey} />
190
+ <Astro.self blocks={col} depth={depth} seen={seen} {...pass} />
73
191
  </div>
74
192
  ))}
75
193
  </div>
@@ -79,8 +197,150 @@ const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).pro
79
197
  const form = formMap.get(p.formId);
80
198
  return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} /> : null;
81
199
  }
200
+ case "section": {
201
+ const inner = <Astro.self blocks={arr(p.children)} depth={depth} seen={seen} {...pass} />;
202
+ const mw = Number(p.maxWidth) || 0;
203
+ return (
204
+ <section
205
+ class="bcms-section"
206
+ style={[
207
+ p.background ? `background:${p.background}` : "",
208
+ p.paddingY != null ? `padding:${Number(p.paddingY) || 0}px 0` : "",
209
+ ].filter(Boolean).join(";") || undefined}
210
+ >
211
+ {mw ? <div class="bcms-section-inner" style={`max-width:${mw}px;margin:0 auto`}>{inner}</div> : inner}
212
+ </section>
213
+ );
214
+ }
215
+ case "navbar": {
216
+ const logo = p.logo && typeof p.logo === "object" ? p.logo : null;
217
+ return (
218
+ <nav class="bcms-navbar">
219
+ {logo?.src ? (
220
+ <a class="bcms-navbar-logo" href={logo.href ?? "/"}>
221
+ <img src={logo.src} alt={logo.alt ?? ""} />
222
+ </a>
223
+ ) : null}
224
+ <div class="bcms-navbar-links">
225
+ {arr(p.links).map((l: any) => <a href={l?.href ?? "#"}>{l?.label ?? ""}</a>)}
226
+ </div>
227
+ {p.cta?.text ? <a class="bcms-btn bcms-btn-primary" href={p.cta.href ?? "#"}>{p.cta.text}</a> : null}
228
+ </nav>
229
+ );
230
+ }
231
+ case "footer":
232
+ return (
233
+ <footer class="bcms-footer">
234
+ <div class="bcms-footer-cols">
235
+ {arr(p.columns).map((col: any) => (
236
+ <div class="bcms-footer-col">
237
+ {col?.heading ? <h4>{col.heading}</h4> : null}
238
+ <ul>
239
+ {arr(col?.links).map((l: any) => <li><a href={l?.href ?? "#"}>{l?.label ?? ""}</a></li>)}
240
+ </ul>
241
+ </div>
242
+ ))}
243
+ </div>
244
+ {p.copyright ? <p class="bcms-footer-copy">{p.copyright}</p> : null}
245
+ </footer>
246
+ );
247
+ case "slider":
248
+ // `hidden` on the inactive slides — NOT just the `data-active` hook the hosted
249
+ // renderer uses — because that renderer ships BASE_CSS with it and this package
250
+ // ships no CSS at all. Without it every slide would stack on an unstyled host.
251
+ // The script keeps both in sync so `.bcms-slide[data-active]` CSS still works.
252
+ return (
253
+ <div
254
+ class="bcms-slider"
255
+ data-bcms-slider
256
+ data-autoplay={p.autoplay ? String(Number(p.interval) || 5000) : undefined}
257
+ data-loop={p.loop ? "" : undefined}
258
+ >
259
+ <div class="bcms-slides">
260
+ {arr(p.slides).map((s: any, i: number) => (
261
+ <div class="bcms-slide" data-active={i === 0 ? "" : undefined} hidden={i !== 0}>
262
+ <Astro.self blocks={arr(s?.children)} depth={depth} seen={seen} {...pass} />
263
+ </div>
264
+ ))}
265
+ </div>
266
+ <button class="bcms-slider-prev" data-prev aria-label="Previous">&#8249;</button>
267
+ <button class="bcms-slider-next" data-next aria-label="Next">&#8250;</button>
268
+ </div>
269
+ );
270
+ case "tabs":
271
+ return (
272
+ <div class="bcms-tabs" data-bcms-tabs>
273
+ <div class="bcms-tablist" role="tablist">
274
+ {arr(p.tabs).map((t: any, i: number) => (
275
+ <button role="tab" class="bcms-tab" aria-selected={i === 0 ? "true" : "false"} data-tab={String(i)}>
276
+ {t?.label ?? ""}
277
+ </button>
278
+ ))}
279
+ </div>
280
+ {arr(p.tabs).map((t: any, i: number) => (
281
+ <div role="tabpanel" class="bcms-tabpanel" data-panel={String(i)} hidden={i !== 0}>
282
+ <Astro.self blocks={arr(t?.children)} depth={depth} seen={seen} {...pass} />
283
+ </div>
284
+ ))}
285
+ </div>
286
+ );
287
+ case "component": {
288
+ const resolved = resolveComponent(p);
289
+ if (!resolved) return null;
290
+ return <Astro.self blocks={resolved.blocks} depth={depth + 1} seen={[...seen, resolved.id]} {...pass} />;
291
+ }
82
292
  default:
83
293
  return null;
84
294
  }
295
+ })();
296
+ // FLO-264: honour the block's bounded style tokens here too, so Astro output matches
297
+ // the builder canvas, the Next adapter and the hosted site.
298
+ return style && node != null ? <div style={style}>{node}</div> : node;
85
299
  })}
86
300
  </div>
301
+
302
+ {needsSlider && (
303
+ <script is:inline>
304
+ // Vanilla prev/next + optional autoplay, mirroring render-page.ts's SLIDER_SCRIPT.
305
+ // `bcmsSliderBound` makes binding idempotent: two top-level <BcmsBlocks> on one page
306
+ // each inline a copy, and every copy sees every slider (the same footgun that had
307
+ // <BcmsForm> submitting twice).
308
+ document.querySelectorAll("[data-bcms-slider]").forEach(function (root) {
309
+ if (root.dataset.bcmsSliderBound) return;
310
+ root.dataset.bcmsSliderBound = "1";
311
+ var slides = root.querySelectorAll(".bcms-slide");
312
+ if (!slides.length) return;
313
+ var i = 0;
314
+ function show(n) {
315
+ i = (n + slides.length) % slides.length;
316
+ slides.forEach(function (s, k) {
317
+ if (k === i) { s.setAttribute("data-active", ""); s.removeAttribute("hidden"); }
318
+ else { s.removeAttribute("data-active"); s.setAttribute("hidden", ""); }
319
+ });
320
+ }
321
+ var prev = root.querySelector("[data-prev]"), next = root.querySelector("[data-next]");
322
+ if (prev) prev.addEventListener("click", function () { show(i - 1); });
323
+ if (next) next.addEventListener("click", function () { show(i + 1); });
324
+ var ms = parseInt(root.getAttribute("data-autoplay") || "0", 10);
325
+ if (ms > 0) setInterval(function () { show(i + 1); }, ms);
326
+ });
327
+ </script>
328
+ )}
329
+
330
+ {needsTabs && (
331
+ <script is:inline>
332
+ // Toggles aria-selected + panel visibility, mirroring render-page.ts's TABS_SCRIPT.
333
+ // Idempotent for the same reason as the slider runtime above.
334
+ document.querySelectorAll("[data-bcms-tabs]").forEach(function (root) {
335
+ if (root.dataset.bcmsTabsBound) return;
336
+ root.dataset.bcmsTabsBound = "1";
337
+ var tabs = root.querySelectorAll("[role=tab]"), panels = root.querySelectorAll("[role=tabpanel]");
338
+ tabs.forEach(function (tab, idx) {
339
+ tab.addEventListener("click", function () {
340
+ tabs.forEach(function (t, k) { t.setAttribute("aria-selected", k === idx ? "true" : "false"); });
341
+ panels.forEach(function (pan, k) { if (k === idx) { pan.removeAttribute("hidden"); } else { pan.setAttribute("hidden", ""); } });
342
+ });
343
+ });
344
+ });
345
+ </script>
346
+ )}
@@ -30,27 +30,67 @@ const INPUT_TYPE: Partial<Record<DeliveryFormField["type"], string>> = {
30
30
  const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
31
31
  ---
32
32
 
33
- <form class={`bcms-form${className ? ` ${className}` : ""}`} method="post" action={action} data-bcms-form>
33
+ <form
34
+ class={`bcms-form${className ? ` ${className}` : ""}`}
35
+ method="post"
36
+ action={action}
37
+ data-bcms-form={form.id}
38
+ data-bcms-form-success={form.successMessage ?? "Thanks!"}
39
+ data-bcms-form-redirect={form.redirectUrl ?? ""}
40
+ >
34
41
  {form.fields?.map((f) => {
35
- const wrapAttrs = f.showIf ? { "data-bcms-showif": JSON.stringify(f.showIf) } : {};
42
+ const wrapAttrs = {
43
+ "data-bcms-form-field": f.key,
44
+ ...(f.showIf ? { "data-bcms-showif": JSON.stringify(f.showIf) } : {}),
45
+ };
46
+ // Every author-editable string carries a `data-bcms-form-part` so the Visual Editor can
47
+ // repaint it live. A string the dock can edit but cannot mark shows stale on the canvas.
48
+ const help = f.helpText
49
+ ? <span class="bcms-field-help" data-bcms-form-part="help">{f.helpText}</span>
50
+ : null;
36
51
  if (f.type === "hidden") return <input type="hidden" name={f.key} value={f.defaultValue ?? ""} />;
37
52
  if (f.type === "checkbox" || f.type === "consent")
38
53
  return (
39
54
  <label class="bcms-check" {...wrapAttrs}>
40
55
  <input type="checkbox" name={f.key} required={f.required} />
41
- <span>{f.label}</span>
56
+ {/* The ` *` marker is emitted for EVERY field type, by every renderer — the
57
+ Visual Editor's live patch rewrites this span as `label + marker`, so a
58
+ renderer that omitted it would drop the star on edit and grow one back on
59
+ the next reload. */}
60
+ <span data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</span>
61
+ {help}
42
62
  </label>
43
63
  );
64
+ // Pick-one / pick-many are option GROUPS. Without this branch both fell through
65
+ // to the `<input type={INPUT_TYPE[f.type] ?? "text"}>` below and shipped as a
66
+ // single-line text box on every Astro site.
67
+ if (f.type === "radio" || f.type === "checkboxes") {
68
+ const multi = f.type === "checkboxes";
69
+ return (
70
+ <fieldset class={`bcms-field bcms-field--${multi ? "checkboxes" : "radio"}`} {...wrapAttrs}>
71
+ <legend data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</legend>
72
+ {(f.options ?? []).map((o) => (
73
+ <label class="bcms-option">
74
+ {/* `required` on each radio is the native "pick one"; on checkboxes it
75
+ would demand every box, so the API enforces non-empty instead. */}
76
+ <input type={multi ? "checkbox" : "radio"} name={f.key} value={o} required={!multi && f.required} />
77
+ <span data-bcms-form-part="option">{o}</span>
78
+ </label>
79
+ ))}
80
+ {help}
81
+ </fieldset>
82
+ );
83
+ }
44
84
  return (
45
85
  <div class="bcms-field" {...wrapAttrs}>
46
- <label for={`bcms-f-${f.key}`}>{f.label}{f.required ? " *" : ""}</label>
86
+ <label for={`bcms-f-${f.key}`} data-bcms-form-part="label">{f.label}{f.required ? " *" : ""}</label>
47
87
  {f.type === "textarea" ? (
48
88
  <textarea id={`bcms-f-${f.key}`} name={f.key} placeholder={f.placeholder} required={f.required}>
49
89
  {f.defaultValue ?? ""}
50
90
  </textarea>
51
91
  ) : f.type === "select" ? (
52
92
  <select id={`bcms-f-${f.key}`} name={f.key} required={f.required}>
53
- {(f.options ?? []).map((o) => <option value={o}>{o}</option>)}
93
+ {(f.options ?? []).map((o) => <option value={o} data-bcms-form-part="option">{o}</option>)}
54
94
  </select>
55
95
  ) : (
56
96
  <input
@@ -62,6 +102,7 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
62
102
  required={f.required}
63
103
  />
64
104
  )}
105
+ {help}
65
106
  </div>
66
107
  );
67
108
  })}
@@ -78,12 +119,19 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
78
119
 
79
120
  {showTurnstile && <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>}
80
121
 
81
- <script is:inline data-bcms-form-success={form.successMessage ?? "Thanks!"} data-bcms-form-redirect={form.redirectUrl ?? ""}>
122
+ <script is:inline>
123
+ // One copy of this script ships per <BcmsForm> instance, and each copy sees EVERY
124
+ // form on the page — so two forms meant four bindings and a double submit. The
125
+ // `bcmsFormBound` flag makes binding idempotent no matter how many copies run,
126
+ // and the success/redirect config now lives on the FORM rather than on the script
127
+ // element, so form A's script can no longer apply form B's success message.
82
128
  document.querySelectorAll("form[data-bcms-form]").forEach((form) => {
129
+ if (form.dataset.bcmsFormBound) return;
130
+ form.dataset.bcmsFormBound = "1";
131
+
83
132
  const msg = form.querySelector(".bcms-form-msg");
84
- const cfg = document.currentScript;
85
- const successMsg = cfg?.getAttribute("data-bcms-form-success") || "Thanks!";
86
- const redirect = cfg?.getAttribute("data-bcms-form-redirect") || "";
133
+ const successMsg = form.getAttribute("data-bcms-form-success") || "Thanks!";
134
+ const redirect = form.getAttribute("data-bcms-form-redirect") || "";
87
135
 
88
136
  // Conditional fields: show/hide based on another field's value.
89
137
  const conds = form.querySelectorAll("[data-bcms-showif]");
@@ -92,7 +140,17 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
92
140
  try {
93
141
  const { field, equals } = JSON.parse(el.getAttribute("data-bcms-showif"));
94
142
  const input = form.elements[field];
95
- const val = input && input.type === "checkbox" ? (input.checked ? "true" : "false") : input?.value;
143
+ // A radio/checkboxes group resolves to a RadioNodeList, which has no `.type`
144
+ // and whose `.value` is "" for checkbox groups — read the checked members.
145
+ let val;
146
+ if (input instanceof RadioNodeList) {
147
+ const checked = Array.from(input).filter((el) => el.checked).map((el) => el.value);
148
+ val = checked.includes(equals) ? equals : checked[0];
149
+ } else if (input && input.type === "checkbox") {
150
+ val = input.checked ? "true" : "false";
151
+ } else {
152
+ val = input?.value;
153
+ }
96
154
  el.style.display = val === equals ? "" : "none";
97
155
  } catch {}
98
156
  });
@@ -102,8 +160,17 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
102
160
 
103
161
  form.addEventListener("submit", async (e) => {
104
162
  e.preventDefault();
163
+ // A `checkboxes` group posts one FormData entry PER checked box under the same
164
+ // name; assigning them one at a time kept only the last. Collect repeats into
165
+ // an array, which is the shape the API and every other renderer expect.
105
166
  const data = {};
106
- new FormData(form).forEach((v, k) => { data[k] = v; });
167
+ new FormData(form).forEach((v, k) => {
168
+ // Turnstile's token is a protocol field, not a form answer — the API reads it
169
+ // from the envelope. Leaving it inside `data` stored it as a submission column.
170
+ if (k === "cf-turnstile-response") return;
171
+ if (k in data) data[k] = Array.isArray(data[k]) ? [...data[k], v] : [data[k], v];
172
+ else data[k] = v;
173
+ });
107
174
  const tsEl = form.querySelector('[name="cf-turnstile-response"]');
108
175
  const body = { data };
109
176
  if (tsEl?.value) body["cf-turnstile-response"] = tsEl.value;
@@ -116,6 +183,7 @@ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
116
183
  if (!res.ok) throw new Error(String(res.status));
117
184
  if (redirect) { window.location.href = redirect; return; }
118
185
  form.reset();
186
+ applyConds();
119
187
  if (msg) { msg.textContent = successMsg; msg.hidden = false; }
120
188
  } catch {
121
189
  if (msg) { msg.textContent = "Something went wrong. Please try again."; msg.hidden = false; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/astro",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,8 +48,9 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@bettercms-ai/image-url": "^0.1.0",
51
- "@bettercms-ai/sdk": "^1.5.0",
52
- "@bettercms-ai/types": "^1.4.0"
51
+ "@bettercms-ai/sdk": "^1.10.0",
52
+ "@bettercms-ai/types": "^1.7.0",
53
+ "@bettercms-ai/ui": "^0.4.0"
53
54
  },
54
55
  "peerDependencies": {
55
56
  "astro": ">=4"