@bettercms-ai/astro 0.5.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 +1 -1
- package/components/BcmsBlocks.astro +240 -38
- package/components/BcmsForm.astro +79 -11
- package/package.json +3 -3
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` (
|
|
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,18 +1,32 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* <BcmsBlocks> — renders a page's blockJson tree natively in Astro
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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).
|
|
14
27
|
*/
|
|
15
28
|
import type { ContentBlock } from "@bettercms-ai/types";
|
|
29
|
+
import { blockStyleToCss } from "@bettercms-ai/types";
|
|
16
30
|
import type { DeliveryComponent, DeliveryForm } from "@bettercms-ai/sdk";
|
|
17
31
|
import { applyOverrides } from "@bettercms-ai/ui/resolve";
|
|
18
32
|
import BcmsImage from "./BcmsImage.astro";
|
|
@@ -29,6 +43,9 @@ interface Props {
|
|
|
29
43
|
depth?: number;
|
|
30
44
|
/** Component ids already on this branch — cycle guard. Internal; do not pass. */
|
|
31
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;
|
|
32
49
|
class?: string;
|
|
33
50
|
}
|
|
34
51
|
|
|
@@ -42,6 +59,7 @@ const {
|
|
|
42
59
|
turnstileSiteKey,
|
|
43
60
|
depth = 0,
|
|
44
61
|
seen = [],
|
|
62
|
+
nested = false,
|
|
45
63
|
class: className,
|
|
46
64
|
} = Astro.props;
|
|
47
65
|
const formMap = new Map(forms.map((f) => [f.id, f]));
|
|
@@ -49,17 +67,90 @@ const componentMap = new Map(components.map((c) => [c.id, c]));
|
|
|
49
67
|
|
|
50
68
|
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
|
51
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 };
|
|
52
138
|
---
|
|
53
139
|
|
|
54
140
|
<div class={className}>
|
|
55
141
|
{blocks.map((block) => {
|
|
56
142
|
const p = anyProps(block);
|
|
143
|
+
const style = styleAttr(block);
|
|
144
|
+
const node = (() => {
|
|
57
145
|
switch (block.type) {
|
|
58
146
|
case "heading": {
|
|
59
147
|
const Tag = `h${clamp(Number(p.level) || 2, 1, 6)}` as keyof HTMLElementTagNameMap;
|
|
60
148
|
return <Tag data-bcms-field={`${block.id}__text`}>{p.text}</Tag>;
|
|
61
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.
|
|
62
152
|
case "text":
|
|
153
|
+
case "richtext":
|
|
63
154
|
return <div class="bcms-text" data-bcms-field={`${block.id}__html`} data-bcms-kind="richtext" set:html={p.html ?? ""} />;
|
|
64
155
|
case "image": {
|
|
65
156
|
// Matches the Next adapter: the image block is click-to-select on the Visual Editor
|
|
@@ -96,14 +187,7 @@ const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).pro
|
|
|
96
187
|
>
|
|
97
188
|
{cols.map((col) => (
|
|
98
189
|
<div class="bcms-col">
|
|
99
|
-
<Astro.self
|
|
100
|
-
blocks={col}
|
|
101
|
-
forms={forms}
|
|
102
|
-
components={components}
|
|
103
|
-
turnstileSiteKey={turnstileSiteKey}
|
|
104
|
-
depth={depth}
|
|
105
|
-
seen={seen}
|
|
106
|
-
/>
|
|
190
|
+
<Astro.self blocks={col} depth={depth} seen={seen} {...pass} />
|
|
107
191
|
</div>
|
|
108
192
|
))}
|
|
109
193
|
</div>
|
|
@@ -113,32 +197,150 @@ const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).pro
|
|
|
113
197
|
const form = formMap.get(p.formId);
|
|
114
198
|
return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} /> : null;
|
|
115
199
|
}
|
|
116
|
-
case "
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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">‹</button>
|
|
267
|
+
<button class="bcms-slider-next" data-next aria-label="Next">›</button>
|
|
268
|
+
</div>
|
|
128
269
|
);
|
|
270
|
+
case "tabs":
|
|
129
271
|
return (
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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>
|
|
138
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} />;
|
|
139
291
|
}
|
|
140
292
|
default:
|
|
141
293
|
return null;
|
|
142
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;
|
|
143
299
|
})}
|
|
144
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
85
|
-
const
|
|
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
|
-
|
|
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) => {
|
|
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.
|
|
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,8 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@bettercms-ai/image-url": "^0.1.0",
|
|
51
|
-
"@bettercms-ai/sdk": "^1.
|
|
52
|
-
"@bettercms-ai/types": "^1.
|
|
51
|
+
"@bettercms-ai/sdk": "^1.10.0",
|
|
52
|
+
"@bettercms-ai/types": "^1.7.0",
|
|
53
53
|
"@bettercms-ai/ui": "^0.4.0"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|