@bettercms-ai/astro 0.5.0 → 0.6.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.
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,18 +1,32 @@
1
1
  ---
2
2
  /**
3
- * <BcmsBlocks> — renders a page's blockJson tree natively in Astro. Covers
4
- * heading, text, image, button, spacer, video, columns, form and `component`,
5
- * with `columns` and `component` recursing via Astro.self and `form`/`component`
6
- * resolving from the `forms`/`components` props. Class names mirror the
7
- * 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
8
  *
9
- * `component` was previously absent, so every placed component instance hit
10
- * `default: return null` — a silently blank section on Astro sites while the
11
- * dashboard canvas and the Next adapter both rendered it. Resolution mirrors
12
- * render-page.ts: the same shared `applyOverrides`, the same depth/cycle guard,
13
- * the same fail-soft on a missing definition.
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 "component": {
117
- const def = componentMap.get(String(p.componentId ?? ""));
118
- // Fail soft on a deleted/unknown component, exactly like the server renderer —
119
- // a dangling reference must never break the build or the page.
120
- if (!def) return null;
121
- // A component's blockJson can embed other components, and nothing validates
122
- // against a cycle at write time, so the guard has to live at render.
123
- if (depth >= MAX_COMPONENT_DEPTH || seen.includes(def.id)) return null;
124
- const resolved = applyOverrides<ContentBlock>(
125
- def.blockJson,
126
- def.props ?? [],
127
- (p.overrides as Record<string, unknown>) ?? {},
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>
128
269
  );
270
+ case "tabs":
129
271
  return (
130
- <Astro.self
131
- blocks={resolved}
132
- forms={forms}
133
- components={components}
134
- turnstileSiteKey={turnstileSiteKey}
135
- depth={depth + 1}
136
- seen={[...seen, def.id]}
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 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; }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import config from "bettercms:config";
7
7
  import imageUrlBuilder, {
8
+ objectPosition,
8
9
  type ImageSource,
9
10
  type ImageFormat,
10
11
  type ImageFit,
@@ -21,6 +22,13 @@ interface Props {
21
22
  sizes?: string;
22
23
  loading?: "lazy" | "eager";
23
24
  class?: string;
25
+ /**
26
+ * Focal point override, 0–1. Normally omitted — the asset carries its own and the
27
+ * server applies it. Pass a MediaAsset with focalX/focalY as `src` and it is picked
28
+ * up automatically.
29
+ */
30
+ focalX?: number | null;
31
+ focalY?: number | null;
24
32
  /** Any other attribute (e.g. `data-bcms-field`) is forwarded to the <img>. */
25
33
  [attr: string]: unknown;
26
34
  }
@@ -36,9 +44,18 @@ const {
36
44
  sizes,
37
45
  loading = "lazy",
38
46
  class: className,
47
+ focalX,
48
+ focalY,
49
+ style,
39
50
  ...rest
40
51
  } = Astro.props;
41
52
 
53
+ // An explicit prop wins; otherwise take whatever the asset object carries.
54
+ const asset = (typeof src === "object" && src !== null ? src : {}) as Record<string, unknown>;
55
+ const fx = focalX ?? (asset.focalX as number | null | undefined);
56
+ const fy = focalY ?? (asset.focalY as number | null | undefined);
57
+ const hasFocal = fx != null || fy != null;
58
+
42
59
  const builder = imageUrlBuilder({ baseUrl: config.mediaUrl });
43
60
  const make = () => {
44
61
  let b = builder(src);
@@ -47,11 +64,19 @@ const make = () => {
47
64
  if (format) b = b.format(format);
48
65
  if (quality) b = b.quality(quality);
49
66
  if (fit) b = b.fit(fit);
67
+ if (hasFocal) b = b.focal(fx ?? 0.5, fy ?? 0.5);
50
68
  return b;
51
69
  };
52
70
 
53
71
  const src1x = make().url();
54
72
  const src2x = make().dpr(2).url();
73
+
74
+ // The CSS half of the same idea, and it is free. When only one dimension is pinned the
75
+ // server does not crop, so the browser does — and `object-position` keeps the subject in
76
+ // frame there too, but only if the stylesheet actually set `object-fit: cover`. Emitting
77
+ // it unconditionally is harmless: `object-position` is inert without `object-fit`.
78
+ const focalStyle = hasFocal ? `object-position:${objectPosition({ x: fx, y: fy })}` : "";
79
+ const mergedStyle = [focalStyle, typeof style === "string" ? style : ""].filter(Boolean).join(";");
55
80
  ---
56
81
 
57
82
  <img
@@ -63,5 +88,6 @@ const src2x = make().dpr(2).url();
63
88
  sizes={sizes}
64
89
  loading={loading}
65
90
  class={className}
91
+ style={mergedStyle || undefined}
66
92
  {...rest}
67
93
  />
@@ -1,20 +1,51 @@
1
1
  ---
2
2
  /**
3
3
  * <BcmsLive> — opens the BetterCMS delivery live SSE stream in the browser and
4
- * reloads the page on every content change (dev + draft preview). Drop it once,
5
- * e.g. in your layout. Build `src` with `liveSrc({ apiUrl, workspace, apiKey })`
6
- * from `@bettercms-ai/astro` (use a content:read key — it is browser-exposed).
4
+ * keeps the page current. Drop it once, e.g. in your layout. Build `src` with
5
+ * `liveSrc({ apiUrl, workspace, apiKey })` from `@bettercms-ai/astro` (use a
6
+ * content:read key — it is browser-exposed).
7
+ *
8
+ * Every change frame is dispatched on `window` as a cancelable `bcms:change`
9
+ * CustomEvent whose `detail` is the frame — including `detail.doc`, the finished
10
+ * delivery document the server already hydrated for you. Patch the DOM from it
11
+ * and call `preventDefault()` to keep the page as it is:
12
+ *
13
+ * window.addEventListener("bcms:change", (e) => {
14
+ * if (e.detail.slug !== "home") return;
15
+ * document.querySelector("h1").textContent = e.detail.doc.data.title;
16
+ * e.preventDefault(); // handled — skip the reload
17
+ * });
18
+ *
19
+ * Nothing handles it → the page reloads, as it always did. That fallback is why
20
+ * this is safe to drop into an existing site unchanged.
21
+ *
22
+ * `tags` narrows which changes count at all. Without it a publish anywhere in the
23
+ * workspace reloads every open tab, including pages that render none of it.
7
24
  */
8
25
  interface Props {
9
26
  src: string;
27
+ /** Only react to changes carrying one of these tags. Omit = react to all. */
28
+ tags?: string[];
10
29
  }
11
- const { src } = Astro.props;
30
+ const { src, tags } = Astro.props;
12
31
  ---
13
32
 
14
- <script is:inline define:vars={{ src }}>
33
+ <script is:inline define:vars={{ src, tags }}>
15
34
  try {
16
35
  const es = new EventSource(src);
17
- es.addEventListener("change", () => window.location.reload());
36
+ es.addEventListener("change", (e) => {
37
+ let detail;
38
+ try {
39
+ detail = JSON.parse(e.data);
40
+ } catch {
41
+ return;
42
+ }
43
+ if (tags?.length && !detail.tags?.some((t) => tags.includes(t))) return;
44
+ const handled = !window.dispatchEvent(
45
+ new CustomEvent("bcms:change", { detail, cancelable: true }),
46
+ );
47
+ if (!handled) window.location.reload();
48
+ });
18
49
  } catch (e) {
19
50
  /* EventSource unsupported — no live refresh */
20
51
  }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,11 @@ export { DeliveryForm, DeliveryFormField, ResolvedSeo, SeoInput, resolveSeo } fr
8
8
  * Resolved configuration shared by the integration, runtime, middleware and
9
9
  * draft routes. Public values are safe to ship to the browser; the API key and
10
10
  * draft secret are read from server env at runtime and never live here.
11
+ *
12
+ * Resolution order is options → `PUBLIC_BCMS_*` env → the build snapshot. Config-time
13
+ * only: this module is reached exclusively through `dist/index.js`, which only an
14
+ * `astro.config.*` imports, so the `node:fs` read below never enters a client bundle.
15
+ * Browser code gets the `bettercms:config` virtual module instead.
11
16
  */
12
17
  declare const DRAFT_COOKIE = "bcms-draft";
13
18
  interface BetterCMSAstroOptions {
@@ -36,8 +41,11 @@ interface PublicConfig {
36
41
  interface ResolvedConfig extends PublicConfig {
37
42
  draft: boolean;
38
43
  }
39
- /** Merge integration options with `PUBLIC_BCMS_*` env, validating the essentials. */
40
- declare function resolveConfig(options: BetterCMSAstroOptions, env?: Record<string, string | undefined>): ResolvedConfig;
44
+ /**
45
+ * Merge integration options with `PUBLIC_BCMS_*` env and the build snapshot, validating the
46
+ * essentials. Precedence: explicit options → env → snapshot.
47
+ */
48
+ declare function resolveConfig(options: BetterCMSAstroOptions, env?: Record<string, string | undefined>, cwd?: string): ResolvedConfig;
41
49
 
42
50
  /**
43
51
  * `bettercms()` — the Astro integration.
package/dist/index.js CHANGED
@@ -1,15 +1,36 @@
1
1
  // src/config.ts
2
+ import { readFileSync } from "fs";
3
+ import { resolve } from "path";
2
4
  var DEFAULT_MEDIA_URL = "https://media.bettercms.ai";
5
+ var SNAPSHOT_FILE = "bcms-content.json";
3
6
  var DRAFT_COOKIE = "bcms-draft";
4
- function resolveConfig(options, env = process.env) {
5
- const apiUrl = (options.apiUrl ?? env.PUBLIC_BCMS_API_URL ?? "").replace(/\/+$/, "");
6
- const workspace = options.workspace ?? env.PUBLIC_BCMS_WORKSPACE ?? "";
7
- if (!apiUrl) throw new Error("[@bettercms-ai/astro] `apiUrl` (or PUBLIC_BCMS_API_URL) is required");
8
- if (!workspace) throw new Error("[@bettercms-ai/astro] `workspace` (or PUBLIC_BCMS_WORKSPACE) is required");
7
+ var snapshotCache;
8
+ function snapshot(cwd) {
9
+ if (snapshotCache === void 0) {
10
+ try {
11
+ snapshotCache = JSON.parse(readFileSync(resolve(cwd, SNAPSHOT_FILE), "utf8"));
12
+ } catch {
13
+ snapshotCache = {};
14
+ }
15
+ }
16
+ return snapshotCache;
17
+ }
18
+ var required = (name, envVar, extra) => new Error(
19
+ `[@bettercms-ai/astro] \`${name}\` is required. Pass \`bettercms({ ${name} })\`, set ${envVar}, or ${extra}
20
+ On BetterCMS hosting these are injected automatically \u2014 if this failed in a hosted build, the repository is running an outdated deploy workflow: publish once, or save Hosting settings, to refresh it.`
21
+ );
22
+ function resolveConfig(options, env = process.env, cwd = process.cwd()) {
23
+ const snap = () => snapshot(cwd);
24
+ const apiUrl = (options.apiUrl ?? env.PUBLIC_BCMS_API_URL ?? snap().apiUrl ?? "").replace(/\/+$/, "");
25
+ const workspace = options.workspace ?? env.PUBLIC_BCMS_WORKSPACE ?? snap().workspace ?? "";
26
+ if (!apiUrl) throw required("apiUrl", "PUBLIC_BCMS_API_URL", `run the content fetch so ${SNAPSHOT_FILE} is present.`);
27
+ if (!workspace) {
28
+ throw required("workspace", "PUBLIC_BCMS_WORKSPACE", `run the content fetch so ${SNAPSHOT_FILE} is present.`);
29
+ }
9
30
  return {
10
31
  apiUrl,
11
32
  workspace,
12
- projectId: options.projectId ?? env.PUBLIC_BCMS_PROJECT_ID,
33
+ projectId: options.projectId ?? env.PUBLIC_BCMS_PROJECT_ID ?? snap().projectId,
13
34
  mediaUrl: (options.mediaUrl ?? env.PUBLIC_BCMS_MEDIA_URL ?? DEFAULT_MEDIA_URL).replace(/\/+$/, ""),
14
35
  studioUrl: options.studioUrl ?? env.PUBLIC_BCMS_STUDIO_URL,
15
36
  draftCookie: DRAFT_COOKIE,
@@ -93,7 +114,10 @@ var integration_default = bettercms;
93
114
  import { createClient } from "@bettercms-ai/sdk";
94
115
  function liveSrc(opts) {
95
116
  const base = opts.apiUrl.replace(/\/+$/, "");
96
- return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;
117
+ const q = new URLSearchParams({ key: opts.apiKey });
118
+ if (opts.depth !== void 0) q.set("depth", String(opts.depth));
119
+ if (opts.drafts) q.set("drafts", "1");
120
+ return `${base}/api/v1/delivery/${opts.workspace}/live?${q}`;
97
121
  }
98
122
  var enc = new TextEncoder();
99
123
 
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts","../src/integration.ts","../src/runtime.ts","../src/index.ts"],"sourcesContent":["/**\n * Resolved configuration shared by the integration, runtime, middleware and\n * draft routes. Public values are safe to ship to the browser; the API key and\n * draft secret are read from server env at runtime and never live here.\n */\n\nconst DEFAULT_MEDIA_URL = \"https://media.bettercms.ai\";\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nexport interface BetterCMSAstroOptions {\n /** Backend base, e.g. `https://api.bettercms.ai`. Falls back to `PUBLIC_BCMS_API_URL`. */\n apiUrl?: string;\n /** Workspace slug. Falls back to `PUBLIC_BCMS_WORKSPACE`. */\n workspace?: string;\n /** Optional project scope for forms. Falls back to `PUBLIC_BCMS_PROJECT_ID`. */\n projectId?: string;\n /** Media host for the image-url builder. Defaults to the prod media host. */\n mediaUrl?: string;\n /** Dashboard base for visual-editing deep links. */\n studioUrl?: string;\n /** Enable the draft-mode middleware + injected draft routes. Default true. */\n draft?: boolean;\n}\n\n/** Browser-safe config — exposed via the `bettercms:config` virtual module. */\nexport interface PublicConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n mediaUrl: string;\n studioUrl?: string;\n draftCookie: string;\n}\n\nexport interface ResolvedConfig extends PublicConfig {\n draft: boolean;\n}\n\n/** Merge integration options with `PUBLIC_BCMS_*` env, validating the essentials. */\nexport function resolveConfig(\n options: BetterCMSAstroOptions,\n env: Record<string, string | undefined> = process.env,\n): ResolvedConfig {\n const apiUrl = (options.apiUrl ?? env.PUBLIC_BCMS_API_URL ?? \"\").replace(/\\/+$/, \"\");\n const workspace = options.workspace ?? env.PUBLIC_BCMS_WORKSPACE ?? \"\";\n if (!apiUrl) throw new Error(\"[@bettercms-ai/astro] `apiUrl` (or PUBLIC_BCMS_API_URL) is required\");\n if (!workspace) throw new Error(\"[@bettercms-ai/astro] `workspace` (or PUBLIC_BCMS_WORKSPACE) is required\");\n\n return {\n apiUrl,\n workspace,\n projectId: options.projectId ?? env.PUBLIC_BCMS_PROJECT_ID,\n mediaUrl: (options.mediaUrl ?? env.PUBLIC_BCMS_MEDIA_URL ?? DEFAULT_MEDIA_URL).replace(/\\/+$/, \"\"),\n studioUrl: options.studioUrl ?? env.PUBLIC_BCMS_STUDIO_URL,\n draftCookie: DRAFT_COOKIE,\n draft: options.draft ?? true,\n };\n}\n\nexport function toPublicConfig(c: ResolvedConfig): PublicConfig {\n const { draft: _draft, ...pub } = c;\n return pub;\n}\n","/**\n * `bettercms()` — the Astro integration.\n *\n * - Registers two virtual modules:\n * `bettercms:client` → server runtime (client + typed loaders) — has the key.\n * `bettercms:config` → browser-safe public config — no secrets.\n * - Adds the draft-mode middleware and injects the draft enable/disable routes.\n */\nimport type { AstroIntegration } from \"astro\";\nimport {\n resolveConfig,\n toPublicConfig,\n type BetterCMSAstroOptions,\n type ResolvedConfig,\n} from \"./config.js\";\n\nconst CLIENT_ID = \"bettercms:client\";\nconst CONFIG_ID = \"bettercms:config\";\nconst RESOLVED = (id: string) => `\\0${id}`;\n\nfunction configModule(cfg: ResolvedConfig): string {\n return `export const config = ${JSON.stringify(toPublicConfig(cfg))};\\nexport default config;\\n`;\n}\n\nfunction clientModule(cfg: ResolvedConfig): string {\n // The key is read from server env at runtime so it never lands in a client\n // bundle. Public values are inlined.\n return [\n `import { createRuntime } from \"@bettercms-ai/astro/runtime\";`,\n `const runtime = createRuntime({`,\n ` apiUrl: ${JSON.stringify(cfg.apiUrl)},`,\n ` workspace: ${JSON.stringify(cfg.workspace)},`,\n ` projectId: ${JSON.stringify(cfg.projectId)},`,\n ` apiKey: import.meta.env.BCMS_API_KEY,`,\n `});`,\n `export const client = runtime.client;`,\n `export const getClient = runtime.getClient;`,\n `export const loadEntry = runtime.loadEntry;`,\n `export const loadEntries = runtime.loadEntries;`,\n `export const loadPage = runtime.loadPage;`,\n `export const loadForms = runtime.loadForms;`,\n `export default runtime;`,\n ].join(\"\\n\");\n}\n\nfunction virtualPlugin(cfg: ResolvedConfig) {\n return {\n name: \"@bettercms-ai/astro:virtual\",\n resolveId(id: string) {\n if (id === CLIENT_ID || id === CONFIG_ID) return RESOLVED(id);\n return null;\n },\n load(id: string) {\n if (id === RESOLVED(CONFIG_ID)) return configModule(cfg);\n if (id === RESOLVED(CLIENT_ID)) return clientModule(cfg);\n return null;\n },\n };\n}\n\nexport function bettercms(options: BetterCMSAstroOptions = {}): AstroIntegration {\n return {\n name: \"@bettercms-ai/astro\",\n hooks: {\n \"astro:config:setup\": ({ updateConfig, addMiddleware, injectRoute, logger }) => {\n const cfg = resolveConfig(options);\n\n updateConfig({ vite: { plugins: [virtualPlugin(cfg)] } });\n\n if (cfg.draft) {\n addMiddleware({ entrypoint: \"@bettercms-ai/astro/middleware\", order: \"pre\" });\n injectRoute({\n pattern: \"/api/bcms/draft/enable\",\n entrypoint: \"@bettercms-ai/astro/draft-enable\",\n prerender: false,\n });\n injectRoute({\n pattern: \"/api/bcms/draft/disable\",\n entrypoint: \"@bettercms-ai/astro/draft-disable\",\n prerender: false,\n });\n logger.info(\"draft mode enabled — /api/bcms/draft/{enable,disable} injected\");\n }\n },\n },\n };\n}\n\nexport default bettercms;\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n */\nexport function liveSrc(opts: { apiUrl: string; workspace: string; apiKey: string }): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n","/**\n * @bettercms-ai/astro — the BetterCMS adapter for Astro.\n *\n * import { defineConfig } from \"astro/config\";\n * import bettercms from \"@bettercms-ai/astro\";\n *\n * export default defineConfig({\n * integrations: [bettercms({ apiUrl, workspace })],\n * output: \"server\",\n * });\n *\n * Then in any page: `import { loadPage } from \"bettercms:client\"`.\n */\nexport { bettercms, default } from \"./integration.js\";\nexport {\n resolveConfig,\n DRAFT_COOKIE,\n type BetterCMSAstroOptions,\n type PublicConfig,\n type ResolvedConfig,\n} from \"./config.js\";\nexport { liveSrc } from \"./runtime.js\";\nexport type { BcmsLocals, BetterCMSRuntime, RuntimeConfig } from \"./runtime.js\";\n\n// Image URL builder, re-exported for `<BcmsImage>` and host code.\nexport { default as imageUrlBuilder, imageUrl } from \"@bettercms-ai/image-url\";\nexport type {\n ImageUrlBuilder,\n ImageSource,\n ImageFormat,\n ImageFit,\n} from \"@bettercms-ai/image-url\";\n\n// Delivery read types, re-exported for typing page/entry data.\nexport type {\n Perspective,\n DeliveryEntry,\n DeliveryPage,\n DeliveryList,\n PageMetaJson,\n SiteSeoDefaults,\n ContentBlock,\n} from \"@bettercms-ai/types\";\nexport type { DeliveryForm, DeliveryFormField } from \"@bettercms-ai/sdk\";\n\n// SEO (FLO-302): page-over-site meta resolver. In a page's frontmatter,\n// `const seo = resolveSeo(page, siteDefaults)`, then map `seo.*` into `<head>`.\nexport { resolveSeo } from \"@bettercms-ai/sdk\";\nexport type { SeoInput, ResolvedSeo } from \"@bettercms-ai/sdk\";\n"],"mappings":"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","names":["default"]}
1
+ {"version":3,"sources":["../src/config.ts","../src/integration.ts","../src/runtime.ts","../src/index.ts"],"sourcesContent":["/**\n * Resolved configuration shared by the integration, runtime, middleware and\n * draft routes. Public values are safe to ship to the browser; the API key and\n * draft secret are read from server env at runtime and never live here.\n *\n * Resolution order is options → `PUBLIC_BCMS_*` env → the build snapshot. Config-time\n * only: this module is reached exclusively through `dist/index.js`, which only an\n * `astro.config.*` imports, so the `node:fs` read below never enters a client bundle.\n * Browser code gets the `bettercms:config` virtual module instead.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nconst DEFAULT_MEDIA_URL = \"https://media.bettercms.ai\";\nconst SNAPSHOT_FILE = \"bcms-content.json\";\nexport const DRAFT_COOKIE = \"bcms-draft\";\n\nexport interface BetterCMSAstroOptions {\n /** Backend base, e.g. `https://api.bettercms.ai`. Falls back to `PUBLIC_BCMS_API_URL`. */\n apiUrl?: string;\n /** Workspace slug. Falls back to `PUBLIC_BCMS_WORKSPACE`. */\n workspace?: string;\n /** Optional project scope for forms. Falls back to `PUBLIC_BCMS_PROJECT_ID`. */\n projectId?: string;\n /** Media host for the image-url builder. Defaults to the prod media host. */\n mediaUrl?: string;\n /** Dashboard base for visual-editing deep links. */\n studioUrl?: string;\n /** Enable the draft-mode middleware + injected draft routes. Default true. */\n draft?: boolean;\n}\n\n/** Browser-safe config — exposed via the `bettercms:config` virtual module. */\nexport interface PublicConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n mediaUrl: string;\n studioUrl?: string;\n draftCookie: string;\n}\n\nexport interface ResolvedConfig extends PublicConfig {\n draft: boolean;\n}\n\n/** The fields of the deploy Action's content snapshot that identify the site. */\ninterface Snapshot {\n apiUrl?: string;\n workspace?: string;\n projectId?: string;\n}\n\n/**\n * Read `bcms-content.json` — the snapshot the deploy Action writes next to the build,\n * BEFORE `astro build` runs — which already carries the workspace this site belongs to.\n *\n * This is what lets `bettercms()` work with no arguments and no env at all. Not reading it\n * was the whole bug: a hosted build had the answer sitting in its own working directory and\n * threw anyway, and every starter template had to reimplement this by hand to survive.\n *\n * Lazy: only consulted when options and env both miss, so the configured path does no I/O.\n * Absent/corrupt file ⇒ `{}`, and the caller raises the actionable error below.\n */\nlet snapshotCache: Snapshot | undefined;\nfunction snapshot(cwd: string): Snapshot {\n if (snapshotCache === undefined) {\n try {\n snapshotCache = JSON.parse(readFileSync(resolve(cwd, SNAPSHOT_FILE), \"utf8\")) as Snapshot;\n } catch {\n snapshotCache = {};\n }\n }\n return snapshotCache;\n}\n\n/** Test seam — the snapshot is memoised for the life of the process. */\nexport function resetSnapshotCache(): void {\n snapshotCache = undefined;\n}\n\nconst required = (name: string, envVar: string, extra: string) =>\n new Error(\n `[@bettercms-ai/astro] \\`${name}\\` is required. Pass \\`bettercms({ ${name} })\\`, set ` +\n `${envVar}, or ${extra}\\n` +\n `On BetterCMS hosting these are injected automatically — if this failed in a hosted ` +\n `build, the repository is running an outdated deploy workflow: publish once, or save ` +\n `Hosting settings, to refresh it.`,\n );\n\n/**\n * Merge integration options with `PUBLIC_BCMS_*` env and the build snapshot, validating the\n * essentials. Precedence: explicit options → env → snapshot.\n */\nexport function resolveConfig(\n options: BetterCMSAstroOptions,\n env: Record<string, string | undefined> = process.env,\n cwd: string = process.cwd(),\n): ResolvedConfig {\n const snap = (): Snapshot => snapshot(cwd);\n\n const apiUrl = (options.apiUrl ?? env.PUBLIC_BCMS_API_URL ?? snap().apiUrl ?? \"\").replace(/\\/+$/, \"\");\n const workspace = options.workspace ?? env.PUBLIC_BCMS_WORKSPACE ?? snap().workspace ?? \"\";\n // No default origin on purpose: guessing one produces a site that builds cleanly against\n // the wrong backend, which is far harder to notice than this error.\n if (!apiUrl) throw required(\"apiUrl\", \"PUBLIC_BCMS_API_URL\", `run the content fetch so ${SNAPSHOT_FILE} is present.`);\n if (!workspace) {\n throw required(\"workspace\", \"PUBLIC_BCMS_WORKSPACE\", `run the content fetch so ${SNAPSHOT_FILE} is present.`);\n }\n\n return {\n apiUrl,\n workspace,\n projectId: options.projectId ?? env.PUBLIC_BCMS_PROJECT_ID ?? snap().projectId,\n mediaUrl: (options.mediaUrl ?? env.PUBLIC_BCMS_MEDIA_URL ?? DEFAULT_MEDIA_URL).replace(/\\/+$/, \"\"),\n studioUrl: options.studioUrl ?? env.PUBLIC_BCMS_STUDIO_URL,\n draftCookie: DRAFT_COOKIE,\n draft: options.draft ?? true,\n };\n}\n\nexport function toPublicConfig(c: ResolvedConfig): PublicConfig {\n const { draft: _draft, ...pub } = c;\n return pub;\n}\n","/**\n * `bettercms()` — the Astro integration.\n *\n * - Registers two virtual modules:\n * `bettercms:client` → server runtime (client + typed loaders) — has the key.\n * `bettercms:config` → browser-safe public config — no secrets.\n * - Adds the draft-mode middleware and injects the draft enable/disable routes.\n */\nimport type { AstroIntegration } from \"astro\";\nimport {\n resolveConfig,\n toPublicConfig,\n type BetterCMSAstroOptions,\n type ResolvedConfig,\n} from \"./config.js\";\n\nconst CLIENT_ID = \"bettercms:client\";\nconst CONFIG_ID = \"bettercms:config\";\nconst RESOLVED = (id: string) => `\\0${id}`;\n\nfunction configModule(cfg: ResolvedConfig): string {\n return `export const config = ${JSON.stringify(toPublicConfig(cfg))};\\nexport default config;\\n`;\n}\n\nfunction clientModule(cfg: ResolvedConfig): string {\n // The key is read from server env at runtime so it never lands in a client\n // bundle. Public values are inlined.\n return [\n `import { createRuntime } from \"@bettercms-ai/astro/runtime\";`,\n `const runtime = createRuntime({`,\n ` apiUrl: ${JSON.stringify(cfg.apiUrl)},`,\n ` workspace: ${JSON.stringify(cfg.workspace)},`,\n ` projectId: ${JSON.stringify(cfg.projectId)},`,\n ` apiKey: import.meta.env.BCMS_API_KEY,`,\n `});`,\n `export const client = runtime.client;`,\n `export const getClient = runtime.getClient;`,\n `export const loadEntry = runtime.loadEntry;`,\n `export const loadEntries = runtime.loadEntries;`,\n `export const loadPage = runtime.loadPage;`,\n `export const loadForms = runtime.loadForms;`,\n `export default runtime;`,\n ].join(\"\\n\");\n}\n\nfunction virtualPlugin(cfg: ResolvedConfig) {\n return {\n name: \"@bettercms-ai/astro:virtual\",\n resolveId(id: string) {\n if (id === CLIENT_ID || id === CONFIG_ID) return RESOLVED(id);\n return null;\n },\n load(id: string) {\n if (id === RESOLVED(CONFIG_ID)) return configModule(cfg);\n if (id === RESOLVED(CLIENT_ID)) return clientModule(cfg);\n return null;\n },\n };\n}\n\nexport function bettercms(options: BetterCMSAstroOptions = {}): AstroIntegration {\n return {\n name: \"@bettercms-ai/astro\",\n hooks: {\n \"astro:config:setup\": ({ updateConfig, addMiddleware, injectRoute, logger }) => {\n const cfg = resolveConfig(options);\n\n updateConfig({ vite: { plugins: [virtualPlugin(cfg)] } });\n\n if (cfg.draft) {\n addMiddleware({ entrypoint: \"@bettercms-ai/astro/middleware\", order: \"pre\" });\n injectRoute({\n pattern: \"/api/bcms/draft/enable\",\n entrypoint: \"@bettercms-ai/astro/draft-enable\",\n prerender: false,\n });\n injectRoute({\n pattern: \"/api/bcms/draft/disable\",\n entrypoint: \"@bettercms-ai/astro/draft-disable\",\n prerender: false,\n });\n logger.info(\"draft mode enabled — /api/bcms/draft/{enable,disable} injected\");\n }\n },\n },\n };\n}\n\nexport default bettercms;\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n *\n * `depth` sets the hydration depth of the document the stream pushes (default 1,\n * matching the delivery API). `drafts` streams autosaves so a preview surface\n * repaints as the editor types — it needs a `content:read:draft` key, and the\n * server ignores the flag for any other key rather than failing the connection.\n * Never enable it on a public site.\n */\nexport function liveSrc(opts: {\n apiUrl: string;\n workspace: string;\n apiKey: string;\n depth?: 0 | 1 | 2;\n drafts?: boolean;\n}): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n const q = new URLSearchParams({ key: opts.apiKey });\n if (opts.depth !== undefined) q.set(\"depth\", String(opts.depth));\n if (opts.drafts) q.set(\"drafts\", \"1\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?${q}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n","/**\n * @bettercms-ai/astro — the BetterCMS adapter for Astro.\n *\n * import { defineConfig } from \"astro/config\";\n * import bettercms from \"@bettercms-ai/astro\";\n *\n * export default defineConfig({\n * integrations: [bettercms({ apiUrl, workspace })],\n * output: \"server\",\n * });\n *\n * Then in any page: `import { loadPage } from \"bettercms:client\"`.\n */\nexport { bettercms, default } from \"./integration.js\";\nexport {\n resolveConfig,\n DRAFT_COOKIE,\n type BetterCMSAstroOptions,\n type PublicConfig,\n type ResolvedConfig,\n} from \"./config.js\";\nexport { liveSrc } from \"./runtime.js\";\nexport type { BcmsLocals, BetterCMSRuntime, RuntimeConfig } from \"./runtime.js\";\n\n// Image URL builder, re-exported for `<BcmsImage>` and host code.\nexport { default as imageUrlBuilder, imageUrl } from \"@bettercms-ai/image-url\";\nexport type {\n ImageUrlBuilder,\n ImageSource,\n ImageFormat,\n ImageFit,\n} from \"@bettercms-ai/image-url\";\n\n// Delivery read types, re-exported for typing page/entry data.\nexport type {\n Perspective,\n DeliveryEntry,\n DeliveryPage,\n DeliveryList,\n PageMetaJson,\n SiteSeoDefaults,\n ContentBlock,\n} from \"@bettercms-ai/types\";\nexport type { DeliveryForm, DeliveryFormField } from \"@bettercms-ai/sdk\";\n\n// SEO (FLO-302): page-over-site meta resolver. In a page's frontmatter,\n// `const seo = resolveSeo(page, siteDefaults)`, then map `seo.*` into `<head>`.\nexport { resolveSeo } from \"@bettercms-ai/sdk\";\nexport type { SeoInput, ResolvedSeo } from \"@bettercms-ai/sdk\";\n"],"mappings":"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","names":["default"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/middleware-entry.ts","../src/runtime.ts"],"sourcesContent":["/**\n * Draft-mode middleware (registered via `addMiddleware`).\n *\n * Reads the signed draft cookie, verifies it with `BCMS_DRAFT_SECRET`, and\n * publishes the result on `Astro.locals.bcms` so loaders pick the draft client.\n */\nimport type { MiddlewareHandler } from \"astro\";\nimport config from \"bettercms:config\";\nimport { verifyValue, type BcmsLocals } from \"./runtime.js\";\n\nexport const onRequest: MiddlewareHandler = async (context, next) => {\n const cookie = context.cookies.get(config.draftCookie)?.value;\n const secret = import.meta.env.BCMS_DRAFT_SECRET as string | undefined;\n\n let locals: BcmsLocals = { draft: false };\n if (cookie && secret) {\n const token = await verifyValue(cookie, secret);\n if (token) locals = { draft: true, token };\n }\n context.locals.bcms = locals;\n\n return next();\n};\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n */\nexport function liveSrc(opts: { apiUrl: string; workspace: string; apiKey: string }): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAOA,OAAO,YAAY;;;ACCnB,SAAS,oBAA8C;AA0EvD,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAQA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AAEzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;;;AD3GO,IAAM,YAA+B,OAAO,SAAS,SAAS;AACnE,QAAM,SAAS,QAAQ,QAAQ,IAAI,OAAO,WAAW,GAAG;AACxD,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,SAAqB,EAAE,OAAO,MAAM;AACxC,MAAI,UAAU,QAAQ;AACpB,UAAM,QAAQ,MAAM,YAAY,QAAQ,MAAM;AAC9C,QAAI,MAAO,UAAS,EAAE,OAAO,MAAM,MAAM;AAAA,EAC3C;AACA,UAAQ,OAAO,OAAO;AAEtB,SAAO,KAAK;AACd;","names":[]}
1
+ {"version":3,"sources":["../src/middleware-entry.ts","../src/runtime.ts"],"sourcesContent":["/**\n * Draft-mode middleware (registered via `addMiddleware`).\n *\n * Reads the signed draft cookie, verifies it with `BCMS_DRAFT_SECRET`, and\n * publishes the result on `Astro.locals.bcms` so loaders pick the draft client.\n */\nimport type { MiddlewareHandler } from \"astro\";\nimport config from \"bettercms:config\";\nimport { verifyValue, type BcmsLocals } from \"./runtime.js\";\n\nexport const onRequest: MiddlewareHandler = async (context, next) => {\n const cookie = context.cookies.get(config.draftCookie)?.value;\n const secret = import.meta.env.BCMS_DRAFT_SECRET as string | undefined;\n\n let locals: BcmsLocals = { draft: false };\n if (cookie && secret) {\n const token = await verifyValue(cookie, secret);\n if (token) locals = { draft: true, token };\n }\n context.locals.bcms = locals;\n\n return next();\n};\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n *\n * `depth` sets the hydration depth of the document the stream pushes (default 1,\n * matching the delivery API). `drafts` streams autosaves so a preview surface\n * repaints as the editor types — it needs a `content:read:draft` key, and the\n * server ignores the flag for any other key rather than failing the connection.\n * Never enable it on a public site.\n */\nexport function liveSrc(opts: {\n apiUrl: string;\n workspace: string;\n apiKey: string;\n depth?: 0 | 1 | 2;\n drafts?: boolean;\n}): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n const q = new URLSearchParams({ key: opts.apiKey });\n if (opts.depth !== undefined) q.set(\"depth\", String(opts.depth));\n if (opts.drafts) q.set(\"drafts\", \"1\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?${q}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAOA,OAAO,YAAY;;;ACCnB,SAAS,oBAA8C;AAyFvD,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAQA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AAEzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;;;AD1HO,IAAM,YAA+B,OAAO,SAAS,SAAS;AACnE,QAAM,SAAS,QAAQ,QAAQ,IAAI,OAAO,WAAW,GAAG;AACxD,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,SAAqB,EAAE,OAAO,MAAM;AACxC,MAAI,UAAU,QAAQ;AACpB,UAAM,QAAQ,MAAM,YAAY,QAAQ,MAAM;AAC9C,QAAI,MAAO,UAAS,EAAE,OAAO,MAAM,MAAM;AAAA,EAC3C;AACA,UAAQ,OAAO,OAAO;AAEtB,SAAO,KAAK;AACd;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/routes/draft-disable.ts","../../src/runtime.ts"],"sourcesContent":["/**\n * GET /api/bcms/draft/disable?redirect=/path — clear the draft cookie.\n */\nimport type { APIRoute } from \"astro\";\nimport config from \"bettercms:config\";\nimport { safeRedirectPath } from \"../runtime.js\";\n\nexport const GET: APIRoute = async (context) => {\n const redirectTo = new URL(context.request.url).searchParams.get(\"redirect\") ?? \"/\";\n context.cookies.delete(config.draftCookie, { path: \"/\" });\n return context.redirect(safeRedirectPath(redirectTo), 302);\n};\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n */\nexport function liveSrc(opts: { apiUrl: string; workspace: string; apiKey: string }): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAIA,OAAO,YAAY;;;ACInB,SAAS,oBAA8C;AA0EvD,IAAM,MAAM,IAAI,YAAY;AAsCrB,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;;;ADnHO,IAAM,MAAgB,OAAO,YAAY;AAC9C,QAAM,aAAa,IAAI,IAAI,QAAQ,QAAQ,GAAG,EAAE,aAAa,IAAI,UAAU,KAAK;AAChF,UAAQ,QAAQ,OAAO,OAAO,aAAa,EAAE,MAAM,IAAI,CAAC;AACxD,SAAO,QAAQ,SAAS,iBAAiB,UAAU,GAAG,GAAG;AAC3D;","names":[]}
1
+ {"version":3,"sources":["../../src/routes/draft-disable.ts","../../src/runtime.ts"],"sourcesContent":["/**\n * GET /api/bcms/draft/disable?redirect=/path — clear the draft cookie.\n */\nimport type { APIRoute } from \"astro\";\nimport config from \"bettercms:config\";\nimport { safeRedirectPath } from \"../runtime.js\";\n\nexport const GET: APIRoute = async (context) => {\n const redirectTo = new URL(context.request.url).searchParams.get(\"redirect\") ?? \"/\";\n context.cookies.delete(config.draftCookie, { path: \"/\" });\n return context.redirect(safeRedirectPath(redirectTo), 302);\n};\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n *\n * `depth` sets the hydration depth of the document the stream pushes (default 1,\n * matching the delivery API). `drafts` streams autosaves so a preview surface\n * repaints as the editor types — it needs a `content:read:draft` key, and the\n * server ignores the flag for any other key rather than failing the connection.\n * Never enable it on a public site.\n */\nexport function liveSrc(opts: {\n apiUrl: string;\n workspace: string;\n apiKey: string;\n depth?: 0 | 1 | 2;\n drafts?: boolean;\n}): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n const q = new URLSearchParams({ key: opts.apiKey });\n if (opts.depth !== undefined) q.set(\"depth\", String(opts.depth));\n if (opts.drafts) q.set(\"drafts\", \"1\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?${q}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAIA,OAAO,YAAY;;;ACInB,SAAS,oBAA8C;AAyFvD,IAAM,MAAM,IAAI,YAAY;AAsCrB,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;;;ADlIO,IAAM,MAAgB,OAAO,YAAY;AAC9C,QAAM,aAAa,IAAI,IAAI,QAAQ,QAAQ,GAAG,EAAE,aAAa,IAAI,UAAU,KAAK;AAChF,UAAQ,QAAQ,OAAO,OAAO,aAAa,EAAE,MAAM,IAAI,CAAC;AACxD,SAAO,QAAQ,SAAS,iBAAiB,UAAU,GAAG,GAAG;AAC3D;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/routes/draft-enable.ts","../../src/runtime.ts"],"sourcesContent":["/**\n * GET /api/bcms/draft/enable?token=<jwt>&redirect=/path\n *\n * Validates the preview token against the backend `/preview/:slug` endpoint\n * (the backend is the signature authority), then sets a signed draft cookie and\n * redirects. Requires `BCMS_DRAFT_SECRET` in the server env.\n */\nimport type { APIRoute } from \"astro\";\nimport config from \"bettercms:config\";\nimport { decodeTokenSlug, safeRedirectPath, signValue } from \"../runtime.js\";\n\nexport const GET: APIRoute = async (context) => {\n const url = new URL(context.request.url);\n const token = url.searchParams.get(\"token\");\n const redirectTo = url.searchParams.get(\"redirect\") ?? \"/\";\n const secret = import.meta.env.BCMS_DRAFT_SECRET as string | undefined;\n\n if (!secret) return new Response(\"BCMS_DRAFT_SECRET is not configured\", { status: 500 });\n if (!token) return new Response(\"Missing token\", { status: 400 });\n\n const slug = decodeTokenSlug(token);\n if (!slug) return new Response(\"Malformed token\", { status: 400 });\n\n const res = await fetch(\n `${config.apiUrl}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`,\n );\n if (!res.ok) return new Response(\"Invalid or expired token\", { status: 401 });\n\n context.cookies.set(config.draftCookie, await signValue(token, secret), {\n httpOnly: true,\n sameSite: \"lax\",\n secure: url.protocol === \"https:\",\n path: \"/\",\n maxAge: 60 * 60 * 24, // 24h, matching the preview-token lifetime\n });\n\n return context.redirect(safeRedirectPath(redirectTo), 302);\n};\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n */\nexport function liveSrc(opts: { apiUrl: string; workspace: string; apiKey: string }): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAQA,OAAO,YAAY;;;ACAnB,SAAS,oBAA8C;AA0EvD,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAiBO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAClB,UAAM,OAAO,KAAK;AAAA,MAChB,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC;AAAA,IACjD;AACA,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;AD7HO,IAAM,MAAgB,OAAO,YAAY;AAC9C,QAAM,MAAM,IAAI,IAAI,QAAQ,QAAQ,GAAG;AACvC,QAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,QAAM,aAAa,IAAI,aAAa,IAAI,UAAU,KAAK;AACvD,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,CAAC,OAAQ,QAAO,IAAI,SAAS,uCAAuC,EAAE,QAAQ,IAAI,CAAC;AACvF,MAAI,CAAC,MAAO,QAAO,IAAI,SAAS,iBAAiB,EAAE,QAAQ,IAAI,CAAC;AAEhE,QAAM,OAAO,gBAAgB,KAAK;AAClC,MAAI,CAAC,KAAM,QAAO,IAAI,SAAS,mBAAmB,EAAE,QAAQ,IAAI,CAAC;AAEjE,QAAM,MAAM,MAAM;AAAA,IAChB,GAAG,OAAO,MAAM,mBAAmB,mBAAmB,IAAI,CAAC,UAAU,mBAAmB,KAAK,CAAC;AAAA,EAChG;AACA,MAAI,CAAC,IAAI,GAAI,QAAO,IAAI,SAAS,4BAA4B,EAAE,QAAQ,IAAI,CAAC;AAE5E,UAAQ,QAAQ,IAAI,OAAO,aAAa,MAAM,UAAU,OAAO,MAAM,GAAG;AAAA,IACtE,UAAU;AAAA,IACV,UAAU;AAAA,IACV,QAAQ,IAAI,aAAa;AAAA,IACzB,MAAM;AAAA,IACN,QAAQ,KAAK,KAAK;AAAA;AAAA,EACpB,CAAC;AAED,SAAO,QAAQ,SAAS,iBAAiB,UAAU,GAAG,GAAG;AAC3D;","names":[]}
1
+ {"version":3,"sources":["../../src/routes/draft-enable.ts","../../src/runtime.ts"],"sourcesContent":["/**\n * GET /api/bcms/draft/enable?token=<jwt>&redirect=/path\n *\n * Validates the preview token against the backend `/preview/:slug` endpoint\n * (the backend is the signature authority), then sets a signed draft cookie and\n * redirects. Requires `BCMS_DRAFT_SECRET` in the server env.\n */\nimport type { APIRoute } from \"astro\";\nimport config from \"bettercms:config\";\nimport { decodeTokenSlug, safeRedirectPath, signValue } from \"../runtime.js\";\n\nexport const GET: APIRoute = async (context) => {\n const url = new URL(context.request.url);\n const token = url.searchParams.get(\"token\");\n const redirectTo = url.searchParams.get(\"redirect\") ?? \"/\";\n const secret = import.meta.env.BCMS_DRAFT_SECRET as string | undefined;\n\n if (!secret) return new Response(\"BCMS_DRAFT_SECRET is not configured\", { status: 500 });\n if (!token) return new Response(\"Missing token\", { status: 400 });\n\n const slug = decodeTokenSlug(token);\n if (!slug) return new Response(\"Malformed token\", { status: 400 });\n\n const res = await fetch(\n `${config.apiUrl}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`,\n );\n if (!res.ok) return new Response(\"Invalid or expired token\", { status: 401 });\n\n context.cookies.set(config.draftCookie, await signValue(token, secret), {\n httpOnly: true,\n sameSite: \"lax\",\n secure: url.protocol === \"https:\",\n path: \"/\",\n maxAge: 60 * 60 * 24, // 24h, matching the preview-token lifetime\n });\n\n return context.redirect(safeRedirectPath(redirectTo), 302);\n};\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n *\n * `depth` sets the hydration depth of the document the stream pushes (default 1,\n * matching the delivery API). `drafts` streams autosaves so a preview surface\n * repaints as the editor types — it needs a `content:read:draft` key, and the\n * server ignores the flag for any other key rather than failing the connection.\n * Never enable it on a public site.\n */\nexport function liveSrc(opts: {\n apiUrl: string;\n workspace: string;\n apiKey: string;\n depth?: 0 | 1 | 2;\n drafts?: boolean;\n}): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n const q = new URLSearchParams({ key: opts.apiKey });\n if (opts.depth !== undefined) q.set(\"depth\", String(opts.depth));\n if (opts.drafts) q.set(\"drafts\", \"1\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?${q}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAQA,OAAO,YAAY;;;ACAnB,SAAS,oBAA8C;AAyFvD,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAiBO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAClB,UAAM,OAAO,KAAK;AAAA,MAChB,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC;AAAA,IACjD;AACA,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;AD5IO,IAAM,MAAgB,OAAO,YAAY;AAC9C,QAAM,MAAM,IAAI,IAAI,QAAQ,QAAQ,GAAG;AACvC,QAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,QAAM,aAAa,IAAI,aAAa,IAAI,UAAU,KAAK;AACvD,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,CAAC,OAAQ,QAAO,IAAI,SAAS,uCAAuC,EAAE,QAAQ,IAAI,CAAC;AACvF,MAAI,CAAC,MAAO,QAAO,IAAI,SAAS,iBAAiB,EAAE,QAAQ,IAAI,CAAC;AAEhE,QAAM,OAAO,gBAAgB,KAAK;AAClC,MAAI,CAAC,KAAM,QAAO,IAAI,SAAS,mBAAmB,EAAE,QAAQ,IAAI,CAAC;AAEjE,QAAM,MAAM,MAAM;AAAA,IAChB,GAAG,OAAO,MAAM,mBAAmB,mBAAmB,IAAI,CAAC,UAAU,mBAAmB,KAAK,CAAC;AAAA,EAChG;AACA,MAAI,CAAC,IAAI,GAAI,QAAO,IAAI,SAAS,4BAA4B,EAAE,QAAQ,IAAI,CAAC;AAE5E,UAAQ,QAAQ,IAAI,OAAO,aAAa,MAAM,UAAU,OAAO,MAAM,GAAG;AAAA,IACtE,UAAU;AAAA,IACV,UAAU;AAAA,IACV,QAAQ,IAAI,aAAa;AAAA,IACzB,MAAM;AAAA,IACN,QAAQ,KAAK,KAAK;AAAA;AAAA,EACpB,CAAC;AAED,SAAO,QAAQ,SAAS,iBAAiB,UAAU,GAAG,GAAG;AAC3D;","names":[]}
package/dist/runtime.d.ts CHANGED
@@ -41,11 +41,19 @@ declare function createRuntime(config: RuntimeConfig): BetterCMSRuntime;
41
41
  /**
42
42
  * Build the live SSE URL for `<BcmsLive>`. The key is a query param because
43
43
  * browser EventSource cannot send headers — use a content:read key.
44
+ *
45
+ * `depth` sets the hydration depth of the document the stream pushes (default 1,
46
+ * matching the delivery API). `drafts` streams autosaves so a preview surface
47
+ * repaints as the editor types — it needs a `content:read:draft` key, and the
48
+ * server ignores the flag for any other key rather than failing the connection.
49
+ * Never enable it on a public site.
44
50
  */
45
51
  declare function liveSrc(opts: {
46
52
  apiUrl: string;
47
53
  workspace: string;
48
54
  apiKey: string;
55
+ depth?: 0 | 1 | 2;
56
+ drafts?: boolean;
49
57
  }): string;
50
58
  /** Sign a value as `value.sig` for tamper-evident cookie storage. */
51
59
  declare function signValue(value: string, secret: string): Promise<string>;
package/dist/runtime.js CHANGED
@@ -20,7 +20,10 @@ function createRuntime(config) {
20
20
  }
21
21
  function liveSrc(opts) {
22
22
  const base = opts.apiUrl.replace(/\/+$/, "");
23
- return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;
23
+ const q = new URLSearchParams({ key: opts.apiKey });
24
+ if (opts.depth !== void 0) q.set("depth", String(opts.depth));
25
+ if (opts.drafts) q.set("drafts", "1");
26
+ return `${base}/api/v1/delivery/${opts.workspace}/live?${q}`;
24
27
  }
25
28
  var enc = new TextEncoder();
26
29
  function base64url(bytes) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n */\nexport function liveSrc(opts: { apiUrl: string; workspace: string; apiKey: string }): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAQA,SAAS,oBAA8C;AA0ChD,SAAS,cAAc,QAAyC;AACrE,QAAM,YAAY,aAAa,EAAE,GAAG,QAAQ,aAAa,YAAY,CAAC;AAEtE,QAAM,YAAY,CAAC,UAA2C;AAC5D,UAAM,OAAO,OAAO,QAAQ;AAC5B,QAAI,MAAM,OAAO;AACf,aAAO,aAAa,EAAE,GAAG,QAAQ,aAAa,UAAU,cAAc,KAAK,MAAM,CAAC;AAAA,IACpF;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA,WAAW,CAAC,OAAO,MAAM,SAAS,UAAU,KAAK,EAAE,SAAS,MAAM,IAAI;AAAA,IACtE,aAAa,CAAC,OAAO,SAAS,UAAU,KAAK,EAAE,YAAY,IAAI;AAAA,IAC/D,UAAU,CAAC,OAAO,SAAS,UAAU,KAAK,EAAE,QAAQ,IAAI;AAAA,IACxD,WAAW,CAAC,OAAO,SAAS,UAAU,KAAK,EAAE,UAAU,IAAI;AAAA,EAC7D;AACF;AAMO,SAAS,QAAQ,MAAqE;AAC3F,QAAM,OAAO,KAAK,OAAO,QAAQ,QAAQ,EAAE;AAC3C,SAAO,GAAG,IAAI,oBAAoB,KAAK,SAAS,aAAa,mBAAmB,KAAK,MAAM,CAAC;AAC9F;AAIA,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAGA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AAEzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;AAGO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAClB,UAAM,OAAO,KAAK;AAAA,MAChB,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC;AAAA,IACjD;AACA,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@bettercms-ai/types\";\n\nexport interface RuntimeConfig {\n apiUrl: string;\n workspace: string;\n projectId?: string;\n /** Server-only delivery key (a `content:read:draft` key for drafts). */\n apiKey?: string;\n}\n\n/** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */\nexport interface BcmsLocals {\n draft: boolean;\n token?: string;\n}\n\ninterface HasLocals {\n locals: { bcms?: BcmsLocals };\n}\n\nexport interface BetterCMSRuntime {\n /** Published-perspective client (use for SSG / non-draft reads). */\n client: BetterCMSReadClient;\n /** Pick the right client for a request (draft client when draft mode is on). */\n getClient(astro?: HasLocals): BetterCMSReadClient;\n loadEntry<T = Record<string, unknown>>(\n astro: HasLocals,\n slug: string,\n opts?: Parameters<BetterCMSReadClient[\"getEntry\"]>[1],\n ): Promise<DeliveryEntry<T>>;\n loadEntries<T = Record<string, unknown>>(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listEntries\"]>[0],\n ): Promise<DeliveryList<DeliveryEntry<T>>>;\n loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;\n loadForms(\n astro: HasLocals,\n opts?: Parameters<BetterCMSReadClient[\"listForms\"]>[0],\n ): Promise<Awaited<ReturnType<BetterCMSReadClient[\"listForms\"]>>>;\n}\n\nexport function createRuntime(config: RuntimeConfig): BetterCMSRuntime {\n const published = createClient({ ...config, perspective: \"published\" });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n return createClient({ ...config, perspective: \"drafts\", previewToken: bcms.token });\n }\n return published;\n };\n\n return {\n client: published,\n getClient,\n loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),\n loadEntries: (astro, opts) => getClient(astro).listEntries(opts),\n loadPage: (astro, slug) => getClient(astro).getPage(slug),\n loadForms: (astro, opts) => getClient(astro).listForms(opts),\n };\n}\n\n/**\n * Build the live SSE URL for `<BcmsLive>`. The key is a query param because\n * browser EventSource cannot send headers — use a content:read key.\n *\n * `depth` sets the hydration depth of the document the stream pushes (default 1,\n * matching the delivery API). `drafts` streams autosaves so a preview surface\n * repaints as the editor types — it needs a `content:read:draft` key, and the\n * server ignores the flag for any other key rather than failing the connection.\n * Never enable it on a public site.\n */\nexport function liveSrc(opts: {\n apiUrl: string;\n workspace: string;\n apiKey: string;\n depth?: 0 | 1 | 2;\n drafts?: boolean;\n}): string {\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n const q = new URLSearchParams({ key: opts.apiKey });\n if (opts.depth !== undefined) q.set(\"depth\", String(opts.depth));\n if (opts.drafts) q.set(\"drafts\", \"1\");\n return `${base}/api/v1/delivery/${opts.workspace}/live?${q}`;\n}\n\n// ── Signed-cookie helpers (HMAC-SHA256, base64url) ──────────────────────────\n\nconst enc = new TextEncoder();\n\nfunction base64url(bytes: ArrayBuffer): string {\n const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));\n return b.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function hmac(value: string, secret: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return base64url(await crypto.subtle.sign(\"HMAC\", key, enc.encode(value)));\n}\n\n/** Sign a value as `value.sig` for tamper-evident cookie storage. */\nexport async function signValue(value: string, secret: string): Promise<string> {\n return `${value}.${await hmac(value, secret)}`;\n}\n\n/** Verify a signed value, returning the original value or null if tampered. */\nexport async function verifyValue(signed: string, secret: string): Promise<string | null> {\n const idx = signed.lastIndexOf(\".\");\n if (idx < 0) return null;\n const value = signed.slice(0, idx);\n const sig = signed.slice(idx + 1);\n const expected = await hmac(value, secret);\n // Constant-time-ish compare via length + char accumulation.\n if (sig.length !== expected.length) return null;\n let diff = 0;\n for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0 ? value : null;\n}\n\n/** Only same-origin relative paths. Blocks \"//evil.com\", \"https://evil.com\", etc. */\nexport function safeRedirectPath(to: string): string {\n return to.startsWith(\"/\") && !to.startsWith(\"//\") ? to : \"/\";\n}\n\n/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/astro",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -48,8 +48,8 @@
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.5.0",
53
53
  "@bettercms-ai/ui": "^0.4.0"
54
54
  },
55
55
  "peerDependencies": {