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