@betttercms/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @betttercms/astro
2
+
3
+ The BetterCMS adapter for Astro — a `bettercms()` integration, a
4
+ `bettercms:client` virtual module, typed content loaders, draft preview, and
5
+ native `.astro` rendering components. The Astro equivalent of `@sanity/astro`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @betttercms/astro
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ ```js
16
+ // astro.config.mjs
17
+ import { defineConfig } from "astro/config";
18
+ import bettercms from "@betttercms/astro";
19
+
20
+ export default defineConfig({
21
+ integrations: [
22
+ bettercms({
23
+ apiUrl: "https://api.bettercms.ai", // or PUBLIC_BCMS_API_URL
24
+ workspace: "my-workspace", // or PUBLIC_BCMS_WORKSPACE
25
+ // projectId, studioUrl, mediaUrl optional
26
+ }),
27
+ ],
28
+ output: "server", // required for draft mode + live reads
29
+ });
30
+ ```
31
+
32
+ Server env (never bundled to the client):
33
+
34
+ ```bash
35
+ BCMS_API_KEY=bcms_pk_... # content:read (or content:read:draft for previews)
36
+ BCMS_DRAFT_SECRET=<32+ chars> # signs the draft cookie
37
+ ```
38
+
39
+ Add the virtual-module + `Astro.locals` types to `src/env.d.ts`:
40
+
41
+ ```ts
42
+ /// <reference types="@betttercms/astro/env" />
43
+ ```
44
+
45
+ ## Reading content
46
+
47
+ ```astro
48
+ ---
49
+ import { loadPage, loadForms } from "bettercms:client";
50
+ import BcmsBlocks from "@betttercms/astro/components/BcmsBlocks.astro";
51
+
52
+ const page = await loadPage(Astro, "home");
53
+ const { items: forms, turnstileSiteKey } = await loadForms(Astro);
54
+ ---
55
+ {page && <BcmsBlocks blocks={page.blocks} forms={forms} turnstileSiteKey={turnstileSiteKey} />}
56
+ ```
57
+
58
+ Loaders take `Astro` so they automatically read drafts when draft mode is on:
59
+ `loadPage`, `loadEntry`, `loadEntries`, `loadForms`. The raw `client` and
60
+ `getClient(Astro)` are exported too.
61
+
62
+ ## Components
63
+
64
+ | Component | Purpose |
65
+ | --- | --- |
66
+ | `@betttercms/astro/components/BcmsBlocks.astro` | Render a page's `blockJson` (all 8 block types). |
67
+ | `@betttercms/astro/components/BcmsForm.astro` | Render + submit a form (conditional fields, honeypot, Turnstile). |
68
+ | `@betttercms/astro/components/BcmsImage.astro` | Optimized image with a 1x/2x srcset via the media transform endpoint. |
69
+
70
+ Markup is class-driven and unstyled — you own the CSS.
71
+
72
+ ## Draft mode
73
+
74
+ The integration injects `/api/bcms/draft/enable?token=<jwt>&redirect=/path` and
75
+ `/api/bcms/draft/disable`. Generate the preview-token link from the dashboard;
76
+ visiting `enable` validates the token against the backend, sets a signed cookie,
77
+ and subsequent loads return draft content. Disable with the `disable` route.
@@ -0,0 +1,81 @@
1
+ ---
2
+ /**
3
+ * <BcmsBlocks> — renders a page's blockJson tree natively in Astro. Covers all
4
+ * 8 block types (heading, text, image, button, spacer, video, columns, form),
5
+ * with `columns` recursing via Astro.self and `form` resolving from the `forms`
6
+ * prop. Class names mirror the server-side renderer.
7
+ */
8
+ import type { ContentBlock } from "@betttercms/types";
9
+ import type { DeliveryForm } from "@betttercms/sdk";
10
+ import BcmsImage from "./BcmsImage.astro";
11
+ import BcmsForm from "./BcmsForm.astro";
12
+
13
+ interface Props {
14
+ blocks: ContentBlock[];
15
+ forms?: DeliveryForm[];
16
+ turnstileSiteKey?: string | null;
17
+ class?: string;
18
+ }
19
+
20
+ const { blocks = [], forms = [], turnstileSiteKey, class: className } = Astro.props;
21
+ const formMap = new Map(forms.map((f) => [f.id, f]));
22
+
23
+ const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
24
+ const anyProps = (b: ContentBlock) => (b as { props?: Record<string, any> }).props ?? {};
25
+ ---
26
+
27
+ <div class={className}>
28
+ {blocks.map((block) => {
29
+ const p = anyProps(block);
30
+ switch (block.type) {
31
+ case "heading": {
32
+ const Tag = `h${clamp(Number(p.level) || 2, 1, 6)}` as keyof HTMLElementTagNameMap;
33
+ return <Tag>{p.text}</Tag>;
34
+ }
35
+ case "text":
36
+ return <div class="bcms-text" set:html={p.html ?? ""} />;
37
+ case "image":
38
+ return p.caption ? (
39
+ <figure>
40
+ <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} />
41
+ <figcaption>{p.caption}</figcaption>
42
+ </figure>
43
+ ) : (
44
+ <BcmsImage src={p.src} alt={p.alt ?? ""} width={p.width} height={p.height} />
45
+ );
46
+ case "button":
47
+ return (
48
+ <a class={`bcms-btn bcms-btn-${p.variant === "secondary" ? "secondary" : "primary"}`} href={p.href ?? "#"}>
49
+ {p.text}
50
+ </a>
51
+ );
52
+ case "spacer":
53
+ return <div class="bcms-spacer" style={`height:${Number(p.height) || 0}px`} />;
54
+ case "video":
55
+ return (
56
+ <video src={p.url} poster={p.poster} controls autoplay={!!p.autoplay} muted={!!p.autoplay} loop={!!p.loop} playsinline />
57
+ );
58
+ case "columns": {
59
+ const cols: ContentBlock[][] = Array.isArray(p.columns) ? p.columns : [];
60
+ return (
61
+ <div
62
+ class="bcms-cols"
63
+ style={`display:grid;grid-template-columns:repeat(${cols.length || 1},1fr);gap:${Number(p.gap) || 0}px`}
64
+ >
65
+ {cols.map((col) => (
66
+ <div class="bcms-col">
67
+ <Astro.self blocks={col} forms={forms} turnstileSiteKey={turnstileSiteKey} />
68
+ </div>
69
+ ))}
70
+ </div>
71
+ );
72
+ }
73
+ case "form": {
74
+ const form = formMap.get(p.formId);
75
+ return form ? <BcmsForm form={form} turnstileSiteKey={turnstileSiteKey} /> : null;
76
+ }
77
+ default:
78
+ return null;
79
+ }
80
+ })}
81
+ </div>
@@ -0,0 +1,125 @@
1
+ ---
2
+ /**
3
+ * <BcmsForm> — renders a BetterCMS form with conditional fields, honeypot and
4
+ * optional Turnstile. Submits JSON to the public forms endpoint with
5
+ * progressive enhancement (falls back to a normal POST without JS).
6
+ *
7
+ * Markup is class-driven and unstyled — the host owns CSS (matches the
8
+ * server-side renderer's class names: bcms-form, bcms-field, bcms-check, …).
9
+ */
10
+ import config from "bettercms:config";
11
+ import type { DeliveryForm, DeliveryFormField } from "@betttercms/sdk";
12
+
13
+ interface Props {
14
+ form: DeliveryForm;
15
+ turnstileSiteKey?: string | null;
16
+ class?: string;
17
+ }
18
+
19
+ const { form, turnstileSiteKey, class: className } = Astro.props;
20
+ const action = `${config.apiUrl}/api/v1/forms/public/${encodeURIComponent(form.id)}/submissions`;
21
+
22
+ const INPUT_TYPE: Partial<Record<DeliveryFormField["type"], string>> = {
23
+ email: "email",
24
+ number: "number",
25
+ phone: "tel",
26
+ date: "date",
27
+ url: "url",
28
+ };
29
+
30
+ const showTurnstile = Boolean(form.turnstileEnabled && turnstileSiteKey);
31
+ ---
32
+
33
+ <form class={`bcms-form${className ? ` ${className}` : ""}`} method="post" action={action} data-bcms-form>
34
+ {form.fields?.map((f) => {
35
+ const wrapAttrs = f.showIf ? { "data-bcms-showif": JSON.stringify(f.showIf) } : {};
36
+ if (f.type === "hidden") return <input type="hidden" name={f.key} value={f.defaultValue ?? ""} />;
37
+ if (f.type === "checkbox" || f.type === "consent")
38
+ return (
39
+ <label class="bcms-check" {...wrapAttrs}>
40
+ <input type="checkbox" name={f.key} required={f.required} />
41
+ <span>{f.label}</span>
42
+ </label>
43
+ );
44
+ return (
45
+ <div class="bcms-field" {...wrapAttrs}>
46
+ <label for={`bcms-f-${f.key}`}>{f.label}{f.required ? " *" : ""}</label>
47
+ {f.type === "textarea" ? (
48
+ <textarea id={`bcms-f-${f.key}`} name={f.key} placeholder={f.placeholder} required={f.required}>
49
+ {f.defaultValue ?? ""}
50
+ </textarea>
51
+ ) : f.type === "select" ? (
52
+ <select id={`bcms-f-${f.key}`} name={f.key} required={f.required}>
53
+ {(f.options ?? []).map((o) => <option value={o}>{o}</option>)}
54
+ </select>
55
+ ) : (
56
+ <input
57
+ type={INPUT_TYPE[f.type] ?? "text"}
58
+ id={`bcms-f-${f.key}`}
59
+ name={f.key}
60
+ placeholder={f.placeholder}
61
+ value={f.defaultValue}
62
+ required={f.required}
63
+ />
64
+ )}
65
+ </div>
66
+ );
67
+ })}
68
+
69
+ {form.honeypotField && (
70
+ <input type="text" name={form.honeypotField} tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" />
71
+ )}
72
+
73
+ {showTurnstile && <div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>}
74
+
75
+ <button type="submit">{form.submitLabel || "Submit"}</button>
76
+ <p class="bcms-form-msg" hidden></p>
77
+ </form>
78
+
79
+ {showTurnstile && <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>}
80
+
81
+ <script is:inline data-bcms-form-success={form.successMessage ?? "Thanks!"} data-bcms-form-redirect={form.redirectUrl ?? ""}>
82
+ document.querySelectorAll("form[data-bcms-form]").forEach((form) => {
83
+ const msg = form.querySelector(".bcms-form-msg");
84
+ const cfg = document.currentScript;
85
+ const successMsg = cfg?.getAttribute("data-bcms-form-success") || "Thanks!";
86
+ const redirect = cfg?.getAttribute("data-bcms-form-redirect") || "";
87
+
88
+ // Conditional fields: show/hide based on another field's value.
89
+ const conds = form.querySelectorAll("[data-bcms-showif]");
90
+ const applyConds = () => {
91
+ conds.forEach((el) => {
92
+ try {
93
+ const { field, equals } = JSON.parse(el.getAttribute("data-bcms-showif"));
94
+ const input = form.elements[field];
95
+ const val = input && input.type === "checkbox" ? (input.checked ? "true" : "false") : input?.value;
96
+ el.style.display = val === equals ? "" : "none";
97
+ } catch {}
98
+ });
99
+ };
100
+ form.addEventListener("input", applyConds);
101
+ applyConds();
102
+
103
+ form.addEventListener("submit", async (e) => {
104
+ e.preventDefault();
105
+ const data = {};
106
+ new FormData(form).forEach((v, k) => { data[k] = v; });
107
+ const tsEl = form.querySelector('[name="cf-turnstile-response"]');
108
+ const body = { data };
109
+ if (tsEl?.value) body["cf-turnstile-response"] = tsEl.value;
110
+ try {
111
+ const res = await fetch(form.action, {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify(body),
115
+ });
116
+ if (!res.ok) throw new Error(String(res.status));
117
+ if (redirect) { window.location.href = redirect; return; }
118
+ form.reset();
119
+ if (msg) { msg.textContent = successMsg; msg.hidden = false; }
120
+ } catch {
121
+ if (msg) { msg.textContent = "Something went wrong. Please try again."; msg.hidden = false; }
122
+ }
123
+ });
124
+ });
125
+ </script>
@@ -0,0 +1,63 @@
1
+ ---
2
+ /**
3
+ * <BcmsImage> — renders an optimized image via the BetterCMS media transform
4
+ * endpoint, with a 1x/2x srcset. Accepts an asset id, a MediaAsset, or a URL.
5
+ */
6
+ import config from "bettercms:config";
7
+ import imageUrlBuilder, {
8
+ type ImageSource,
9
+ type ImageFormat,
10
+ type ImageFit,
11
+ } from "@betttercms/image-url";
12
+
13
+ interface Props {
14
+ src: ImageSource;
15
+ alt?: string;
16
+ width?: number;
17
+ height?: number;
18
+ format?: ImageFormat;
19
+ quality?: number;
20
+ fit?: ImageFit;
21
+ sizes?: string;
22
+ loading?: "lazy" | "eager";
23
+ class?: string;
24
+ }
25
+
26
+ const {
27
+ src,
28
+ alt = "",
29
+ width,
30
+ height,
31
+ format = "webp",
32
+ quality,
33
+ fit,
34
+ sizes,
35
+ loading = "lazy",
36
+ class: className,
37
+ } = Astro.props;
38
+
39
+ const builder = imageUrlBuilder({ baseUrl: config.mediaUrl });
40
+ const make = () => {
41
+ let b = builder(src);
42
+ if (width) b = b.width(width);
43
+ if (height) b = b.height(height);
44
+ if (format) b = b.format(format);
45
+ if (quality) b = b.quality(quality);
46
+ if (fit) b = b.fit(fit);
47
+ return b;
48
+ };
49
+
50
+ const src1x = make().url();
51
+ const src2x = make().dpr(2).url();
52
+ ---
53
+
54
+ <img
55
+ src={src1x}
56
+ srcset={`${src1x} 1x, ${src2x} 2x`}
57
+ alt={alt}
58
+ width={width}
59
+ height={height}
60
+ sizes={sizes}
61
+ loading={loading}
62
+ class={className}
63
+ />
@@ -0,0 +1,21 @@
1
+ ---
2
+ /**
3
+ * <BcmsLive> — opens the BetterCMS delivery live SSE stream in the browser and
4
+ * reloads the page on every content change (dev + draft preview). Drop it once,
5
+ * e.g. in your layout. Build `src` with `liveSrc({ apiUrl, workspace, apiKey })`
6
+ * from `@betttercms/astro` (use a content:read key — it is browser-exposed).
7
+ */
8
+ interface Props {
9
+ src: string;
10
+ }
11
+ const { src } = Astro.props;
12
+ ---
13
+
14
+ <script is:inline define:vars={{ src }}>
15
+ try {
16
+ const es = new EventSource(src);
17
+ es.addEventListener("change", () => window.location.reload());
18
+ } catch (e) {
19
+ /* EventSource unsupported — no live refresh */
20
+ }
21
+ </script>
@@ -0,0 +1,57 @@
1
+ ---
2
+ /**
3
+ * <BcmsVisualEditing> — click-to-edit overlay for draft preview. Decodes the
4
+ * invisible stega provenance embedded in draft string values and, on Alt+Click,
5
+ * opens the matching dashboard editor. Render it only in draft mode.
6
+ *
7
+ * import config from "bettercms:config";
8
+ * {Astro.locals.bcms?.draft && <BcmsVisualEditing studioUrl={config.studioUrl} />}
9
+ *
10
+ * The decoder is inlined (is:inline) so it needs no client bundle; it mirrors the
11
+ * zero-width scheme in @betttercms/sdk's stega module.
12
+ */
13
+ interface Props {
14
+ studioUrl?: string;
15
+ }
16
+ const { studioUrl = "https://dashboard.bettercms.ai" } = Astro.props;
17
+ ---
18
+
19
+ <script is:inline define:vars={{ studioUrl }}>
20
+ (function () {
21
+ var ZERO = "​", ONE = "‌", START = "⁠", END = "⁡";
22
+ var FRAME = new RegExp(START + "([" + ZERO + ONE + "]*)" + END);
23
+
24
+ function fromBits(bits) {
25
+ var bytes = [];
26
+ for (var i = 0; i + 8 <= bits.length; i += 8) {
27
+ var b = 0;
28
+ for (var j = 0; j < 8; j++) b = (b << 1) | (bits[i + j] === ONE ? 1 : 0);
29
+ bytes.push(b);
30
+ }
31
+ return new TextDecoder().decode(new Uint8Array(bytes));
32
+ }
33
+
34
+ function decode(text) {
35
+ var m = text.match(FRAME);
36
+ if (!m) return null;
37
+ try { return JSON.parse(fromBits(m[1])); } catch (e) { return null; }
38
+ }
39
+
40
+ function href(p) {
41
+ var base = String(studioUrl).replace(/\/+$/, "");
42
+ var section = p.type === "page" ? "pages" : "content";
43
+ return base + "/" + section + "/" + encodeURIComponent(p.id) + "?field=" + encodeURIComponent(p.field);
44
+ }
45
+
46
+ document.addEventListener("click", function (e) {
47
+ if (!e.altKey) return;
48
+ var t = e.target && e.target.textContent;
49
+ if (!t) return;
50
+ var p = decode(t);
51
+ if (!p) return;
52
+ e.preventDefault();
53
+ window.open(href(p), "_blank", "noopener");
54
+ }, true);
55
+ document.documentElement.dataset.bcmsVisualEditing = "on";
56
+ })();
57
+ </script>
@@ -0,0 +1,53 @@
1
+ import { AstroIntegration } from 'astro';
2
+ export { BcmsLocals, BetterCMSRuntime, RuntimeConfig, liveSrc } from './runtime.js';
3
+ export { ImageFit, ImageFormat, ImageSource, ImageUrlBuilder, imageUrl, default as imageUrlBuilder } from '@betttercms/image-url';
4
+ export { ContentBlock, DeliveryEntry, DeliveryList, DeliveryPage, Perspective } from '@betttercms/types';
5
+ export { DeliveryForm, DeliveryFormField } from '@betttercms/sdk';
6
+
7
+ /**
8
+ * Resolved configuration shared by the integration, runtime, middleware and
9
+ * draft routes. Public values are safe to ship to the browser; the API key and
10
+ * draft secret are read from server env at runtime and never live here.
11
+ */
12
+ declare const DRAFT_COOKIE = "bcms-draft";
13
+ interface BetterCMSAstroOptions {
14
+ /** Backend base, e.g. `https://api.bettercms.ai`. Falls back to `PUBLIC_BCMS_API_URL`. */
15
+ apiUrl?: string;
16
+ /** Workspace slug. Falls back to `PUBLIC_BCMS_WORKSPACE`. */
17
+ workspace?: string;
18
+ /** Optional project scope for forms. Falls back to `PUBLIC_BCMS_PROJECT_ID`. */
19
+ projectId?: string;
20
+ /** Media host for the image-url builder. Defaults to the prod media host. */
21
+ mediaUrl?: string;
22
+ /** Dashboard base for visual-editing deep links. */
23
+ studioUrl?: string;
24
+ /** Enable the draft-mode middleware + injected draft routes. Default true. */
25
+ draft?: boolean;
26
+ }
27
+ /** Browser-safe config — exposed via the `bettercms:config` virtual module. */
28
+ interface PublicConfig {
29
+ apiUrl: string;
30
+ workspace: string;
31
+ projectId?: string;
32
+ mediaUrl: string;
33
+ studioUrl?: string;
34
+ draftCookie: string;
35
+ }
36
+ interface ResolvedConfig extends PublicConfig {
37
+ draft: boolean;
38
+ }
39
+ /** Merge integration options with `PUBLIC_BCMS_*` env, validating the essentials. */
40
+ declare function resolveConfig(options: BetterCMSAstroOptions, env?: Record<string, string | undefined>): ResolvedConfig;
41
+
42
+ /**
43
+ * `bettercms()` — the Astro integration.
44
+ *
45
+ * - Registers two virtual modules:
46
+ * `bettercms:client` → server runtime (client + typed loaders) — has the key.
47
+ * `bettercms:config` → browser-safe public config — no secrets.
48
+ * - Adds the draft-mode middleware and injects the draft enable/disable routes.
49
+ */
50
+
51
+ declare function bettercms(options?: BetterCMSAstroOptions): AstroIntegration;
52
+
53
+ export { type BetterCMSAstroOptions, DRAFT_COOKIE, type PublicConfig, type ResolvedConfig, bettercms, bettercms as default, resolveConfig };
package/dist/index.js ADDED
@@ -0,0 +1,111 @@
1
+ // src/config.ts
2
+ var DEFAULT_MEDIA_URL = "https://media.bettercms.ai";
3
+ var DRAFT_COOKIE = "bcms-draft";
4
+ function resolveConfig(options, env = process.env) {
5
+ const apiUrl = (options.apiUrl ?? env.PUBLIC_BCMS_API_URL ?? "").replace(/\/+$/, "");
6
+ const workspace = options.workspace ?? env.PUBLIC_BCMS_WORKSPACE ?? "";
7
+ if (!apiUrl) throw new Error("[@betttercms/astro] `apiUrl` (or PUBLIC_BCMS_API_URL) is required");
8
+ if (!workspace) throw new Error("[@betttercms/astro] `workspace` (or PUBLIC_BCMS_WORKSPACE) is required");
9
+ return {
10
+ apiUrl,
11
+ workspace,
12
+ projectId: options.projectId ?? env.PUBLIC_BCMS_PROJECT_ID,
13
+ mediaUrl: (options.mediaUrl ?? env.PUBLIC_BCMS_MEDIA_URL ?? DEFAULT_MEDIA_URL).replace(/\/+$/, ""),
14
+ studioUrl: options.studioUrl ?? env.PUBLIC_BCMS_STUDIO_URL,
15
+ draftCookie: DRAFT_COOKIE,
16
+ draft: options.draft ?? true
17
+ };
18
+ }
19
+ function toPublicConfig(c) {
20
+ const { draft: _draft, ...pub } = c;
21
+ return pub;
22
+ }
23
+
24
+ // src/integration.ts
25
+ var CLIENT_ID = "bettercms:client";
26
+ var CONFIG_ID = "bettercms:config";
27
+ var RESOLVED = (id) => `\0${id}`;
28
+ function configModule(cfg) {
29
+ return `export const config = ${JSON.stringify(toPublicConfig(cfg))};
30
+ export default config;
31
+ `;
32
+ }
33
+ function clientModule(cfg) {
34
+ return [
35
+ `import { createRuntime } from "@betttercms/astro/runtime";`,
36
+ `const runtime = createRuntime({`,
37
+ ` apiUrl: ${JSON.stringify(cfg.apiUrl)},`,
38
+ ` workspace: ${JSON.stringify(cfg.workspace)},`,
39
+ ` projectId: ${JSON.stringify(cfg.projectId)},`,
40
+ ` apiKey: import.meta.env.BCMS_API_KEY,`,
41
+ `});`,
42
+ `export const client = runtime.client;`,
43
+ `export const getClient = runtime.getClient;`,
44
+ `export const loadEntry = runtime.loadEntry;`,
45
+ `export const loadEntries = runtime.loadEntries;`,
46
+ `export const loadPage = runtime.loadPage;`,
47
+ `export const loadForms = runtime.loadForms;`,
48
+ `export default runtime;`
49
+ ].join("\n");
50
+ }
51
+ function virtualPlugin(cfg) {
52
+ return {
53
+ name: "@betttercms/astro:virtual",
54
+ resolveId(id) {
55
+ if (id === CLIENT_ID || id === CONFIG_ID) return RESOLVED(id);
56
+ return null;
57
+ },
58
+ load(id) {
59
+ if (id === RESOLVED(CONFIG_ID)) return configModule(cfg);
60
+ if (id === RESOLVED(CLIENT_ID)) return clientModule(cfg);
61
+ return null;
62
+ }
63
+ };
64
+ }
65
+ function bettercms(options = {}) {
66
+ return {
67
+ name: "@betttercms/astro",
68
+ hooks: {
69
+ "astro:config:setup": ({ updateConfig, addMiddleware, injectRoute, logger }) => {
70
+ const cfg = resolveConfig(options);
71
+ updateConfig({ vite: { plugins: [virtualPlugin(cfg)] } });
72
+ if (cfg.draft) {
73
+ addMiddleware({ entrypoint: "@betttercms/astro/middleware", order: "pre" });
74
+ injectRoute({
75
+ pattern: "/api/bcms/draft/enable",
76
+ entrypoint: "@betttercms/astro/draft-enable",
77
+ prerender: false
78
+ });
79
+ injectRoute({
80
+ pattern: "/api/bcms/draft/disable",
81
+ entrypoint: "@betttercms/astro/draft-disable",
82
+ prerender: false
83
+ });
84
+ logger.info("draft mode enabled \u2014 /api/bcms/draft/{enable,disable} injected");
85
+ }
86
+ }
87
+ }
88
+ };
89
+ }
90
+ var integration_default = bettercms;
91
+
92
+ // src/runtime.ts
93
+ import { createClient } from "@betttercms/sdk";
94
+ function liveSrc(opts) {
95
+ const base = opts.apiUrl.replace(/\/+$/, "");
96
+ return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;
97
+ }
98
+ var enc = new TextEncoder();
99
+
100
+ // src/index.ts
101
+ import { default as default2, imageUrl } from "@betttercms/image-url";
102
+ export {
103
+ DRAFT_COOKIE,
104
+ bettercms,
105
+ integration_default as default,
106
+ imageUrl,
107
+ default2 as imageUrlBuilder,
108
+ liveSrc,
109
+ resolveConfig
110
+ };
111
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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(\"[@betttercms/astro] `apiUrl` (or PUBLIC_BCMS_API_URL) is required\");\n if (!workspace) throw new Error(\"[@betttercms/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 \"@betttercms/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: \"@betttercms/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: \"@betttercms/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: \"@betttercms/astro/middleware\", order: \"pre\" });\n injectRoute({\n pattern: \"/api/bcms/draft/enable\",\n entrypoint: \"@betttercms/astro/draft-enable\",\n prerender: false,\n });\n injectRoute({\n pattern: \"/api/bcms/draft/disable\",\n entrypoint: \"@betttercms/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 @betttercms/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 \"@betttercms/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@betttercms/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/** 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 * @betttercms/astro — the BetterCMS adapter for Astro.\n *\n * import { defineConfig } from \"astro/config\";\n * import bettercms from \"@betttercms/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 \"@betttercms/image-url\";\nexport type {\n ImageUrlBuilder,\n ImageSource,\n ImageFormat,\n ImageFit,\n} from \"@betttercms/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 ContentBlock,\n} from \"@betttercms/types\";\nexport type { DeliveryForm, DeliveryFormField } from \"@betttercms/sdk\";\n"],"mappings":";AAMA,IAAM,oBAAoB;AACnB,IAAM,eAAe;AAgCrB,SAAS,cACd,SACA,MAA0C,QAAQ,KAClC;AAChB,QAAM,UAAU,QAAQ,UAAU,IAAI,uBAAuB,IAAI,QAAQ,QAAQ,EAAE;AACnF,QAAM,YAAY,QAAQ,aAAa,IAAI,yBAAyB;AACpE,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,mEAAmE;AAChG,MAAI,CAAC,UAAW,OAAM,IAAI,MAAM,wEAAwE;AAExG,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,WAAW,QAAQ,aAAa,IAAI;AAAA,IACpC,WAAW,QAAQ,YAAY,IAAI,yBAAyB,mBAAmB,QAAQ,QAAQ,EAAE;AAAA,IACjG,WAAW,QAAQ,aAAa,IAAI;AAAA,IACpC,aAAa;AAAA,IACb,OAAO,QAAQ,SAAS;AAAA,EAC1B;AACF;AAEO,SAAS,eAAe,GAAiC;AAC9D,QAAM,EAAE,OAAO,QAAQ,GAAG,IAAI,IAAI;AAClC,SAAO;AACT;;;AC9CA,IAAM,YAAY;AAClB,IAAM,YAAY;AAClB,IAAM,WAAW,CAAC,OAAe,KAAK,EAAE;AAExC,SAAS,aAAa,KAA6B;AACjD,SAAO,yBAAyB,KAAK,UAAU,eAAe,GAAG,CAAC,CAAC;AAAA;AAAA;AACrE;AAEA,SAAS,aAAa,KAA6B;AAGjD,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,aAAa,KAAK,UAAU,IAAI,MAAM,CAAC;AAAA,IACvC,gBAAgB,KAAK,UAAU,IAAI,SAAS,CAAC;AAAA,IAC7C,gBAAgB,KAAK,UAAU,IAAI,SAAS,CAAC;AAAA,IAC7C;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,IAAI;AACb;AAEA,SAAS,cAAc,KAAqB;AAC1C,SAAO;AAAA,IACL,MAAM;AAAA,IACN,UAAU,IAAY;AACpB,UAAI,OAAO,aAAa,OAAO,UAAW,QAAO,SAAS,EAAE;AAC5D,aAAO;AAAA,IACT;AAAA,IACA,KAAK,IAAY;AACf,UAAI,OAAO,SAAS,SAAS,EAAG,QAAO,aAAa,GAAG;AACvD,UAAI,OAAO,SAAS,SAAS,EAAG,QAAO,aAAa,GAAG;AACvD,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAEO,SAAS,UAAU,UAAiC,CAAC,GAAqB;AAC/E,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,MACL,sBAAsB,CAAC,EAAE,cAAc,eAAe,aAAa,OAAO,MAAM;AAC9E,cAAM,MAAM,cAAc,OAAO;AAEjC,qBAAa,EAAE,MAAM,EAAE,SAAS,CAAC,cAAc,GAAG,CAAC,EAAE,EAAE,CAAC;AAExD,YAAI,IAAI,OAAO;AACb,wBAAc,EAAE,YAAY,gCAAgC,OAAO,MAAM,CAAC;AAC1E,sBAAY;AAAA,YACV,SAAS;AAAA,YACT,YAAY;AAAA,YACZ,WAAW;AAAA,UACb,CAAC;AACD,sBAAY;AAAA,YACV,SAAS;AAAA,YACT,YAAY;AAAA,YACZ,WAAW;AAAA,UACb,CAAC;AACD,iBAAO,KAAK,qEAAgE;AAAA,QAC9E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAEA,IAAO,sBAAQ;;;AChFf,SAAS,oBAA8C;AAmEhD,SAAS,QAAQ,MAAqE;AAC3F,QAAM,OAAO,KAAK,OAAO,QAAQ,QAAQ,EAAE;AAC3C,SAAO,GAAG,IAAI,oBAAoB,KAAK,SAAS,aAAa,mBAAmB,KAAK,MAAM,CAAC;AAC9F;AAIA,IAAM,MAAM,IAAI,YAAY;;;ACzD5B,SAAoB,WAAXA,UAA4B,gBAAgB;","names":["default"]}
@@ -0,0 +1,12 @@
1
+ import { MiddlewareHandler } from 'astro';
2
+
3
+ /**
4
+ * Draft-mode middleware (registered via `addMiddleware`).
5
+ *
6
+ * Reads the signed draft cookie, verifies it with `BCMS_DRAFT_SECRET`, and
7
+ * publishes the result on `Astro.locals.bcms` so loaders pick the draft client.
8
+ */
9
+
10
+ declare const onRequest: MiddlewareHandler;
11
+
12
+ export { onRequest };
@@ -0,0 +1,48 @@
1
+ // src/middleware-entry.ts
2
+ import config from "bettercms:config";
3
+
4
+ // src/runtime.ts
5
+ import { createClient } from "@betttercms/sdk";
6
+ var enc = new TextEncoder();
7
+ function base64url(bytes) {
8
+ const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));
9
+ return b.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
10
+ }
11
+ async function hmac(value, secret) {
12
+ const key = await crypto.subtle.importKey(
13
+ "raw",
14
+ enc.encode(secret),
15
+ { name: "HMAC", hash: "SHA-256" },
16
+ false,
17
+ ["sign"]
18
+ );
19
+ return base64url(await crypto.subtle.sign("HMAC", key, enc.encode(value)));
20
+ }
21
+ async function verifyValue(signed, secret) {
22
+ const idx = signed.lastIndexOf(".");
23
+ if (idx < 0) return null;
24
+ const value = signed.slice(0, idx);
25
+ const sig = signed.slice(idx + 1);
26
+ const expected = await hmac(value, secret);
27
+ if (sig.length !== expected.length) return null;
28
+ let diff = 0;
29
+ for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
30
+ return diff === 0 ? value : null;
31
+ }
32
+
33
+ // src/middleware-entry.ts
34
+ var onRequest = async (context, next) => {
35
+ const cookie = context.cookies.get(config.draftCookie)?.value;
36
+ const secret = import.meta.env.BCMS_DRAFT_SECRET;
37
+ let locals = { draft: false };
38
+ if (cookie && secret) {
39
+ const token = await verifyValue(cookie, secret);
40
+ if (token) locals = { draft: true, token };
41
+ }
42
+ context.locals.bcms = locals;
43
+ return next();
44
+ };
45
+ export {
46
+ onRequest
47
+ };
48
+ //# sourceMappingURL=middleware-entry.js.map
@@ -0,0 +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 @betttercms/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 \"@betttercms/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@betttercms/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/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAOA,OAAO,YAAY;;;ACCnB,SAAS,oBAA8C;AA0EvD,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAQA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AAEzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;;;AD3GO,IAAM,YAA+B,OAAO,SAAS,SAAS;AACnE,QAAM,SAAS,QAAQ,QAAQ,IAAI,OAAO,WAAW,GAAG;AACxD,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,SAAqB,EAAE,OAAO,MAAM;AACxC,MAAI,UAAU,QAAQ;AACpB,UAAM,QAAQ,MAAM,YAAY,QAAQ,MAAM;AAC9C,QAAI,MAAO,UAAS,EAAE,OAAO,MAAM,MAAM;AAAA,EAC3C;AACA,UAAQ,OAAO,OAAO;AAEtB,SAAO,KAAK;AACd;","names":[]}
@@ -0,0 +1,9 @@
1
+ import { APIRoute } from 'astro';
2
+
3
+ /**
4
+ * GET /api/bcms/draft/disable?redirect=/path — clear the draft cookie.
5
+ */
6
+
7
+ declare const GET: APIRoute;
8
+
9
+ export { GET };
@@ -0,0 +1,11 @@
1
+ // src/routes/draft-disable.ts
2
+ import config from "bettercms:config";
3
+ var GET = async (context) => {
4
+ const redirectTo = new URL(context.request.url).searchParams.get("redirect") ?? "/";
5
+ context.cookies.delete(config.draftCookie, { path: "/" });
6
+ return context.redirect(redirectTo, 302);
7
+ };
8
+ export {
9
+ GET
10
+ };
11
+ //# sourceMappingURL=draft-disable.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/routes/draft-disable.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\";\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(redirectTo, 302);\n};\n"],"mappings":";AAIA,OAAO,YAAY;AAEZ,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,YAAY,GAAG;AACzC;","names":[]}
@@ -0,0 +1,13 @@
1
+ import { APIRoute } from 'astro';
2
+
3
+ /**
4
+ * GET /api/bcms/draft/enable?token=<jwt>&redirect=/path
5
+ *
6
+ * Validates the preview token against the backend `/preview/:slug` endpoint
7
+ * (the backend is the signature authority), then sets a signed draft cookie and
8
+ * redirects. Requires `BCMS_DRAFT_SECRET` in the server env.
9
+ */
10
+
11
+ declare const GET: APIRoute;
12
+
13
+ export { GET };
@@ -0,0 +1,64 @@
1
+ // src/routes/draft-enable.ts
2
+ import config from "bettercms:config";
3
+
4
+ // src/runtime.ts
5
+ import { createClient } from "@betttercms/sdk";
6
+ var enc = new TextEncoder();
7
+ function base64url(bytes) {
8
+ const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));
9
+ return b.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
10
+ }
11
+ async function hmac(value, secret) {
12
+ const key = await crypto.subtle.importKey(
13
+ "raw",
14
+ enc.encode(secret),
15
+ { name: "HMAC", hash: "SHA-256" },
16
+ false,
17
+ ["sign"]
18
+ );
19
+ return base64url(await crypto.subtle.sign("HMAC", key, enc.encode(value)));
20
+ }
21
+ async function signValue(value, secret) {
22
+ return `${value}.${await hmac(value, secret)}`;
23
+ }
24
+ function decodeTokenSlug(token) {
25
+ try {
26
+ const part = token.split(".")[1];
27
+ if (!part) return null;
28
+ const json = JSON.parse(
29
+ atob(part.replace(/-/g, "+").replace(/_/g, "/"))
30
+ );
31
+ return json.entrySlug ?? null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // src/routes/draft-enable.ts
38
+ var GET = async (context) => {
39
+ const url = new URL(context.request.url);
40
+ const token = url.searchParams.get("token");
41
+ const redirectTo = url.searchParams.get("redirect") ?? "/";
42
+ const secret = import.meta.env.BCMS_DRAFT_SECRET;
43
+ if (!secret) return new Response("BCMS_DRAFT_SECRET is not configured", { status: 500 });
44
+ if (!token) return new Response("Missing token", { status: 400 });
45
+ const slug = decodeTokenSlug(token);
46
+ if (!slug) return new Response("Malformed token", { status: 400 });
47
+ const res = await fetch(
48
+ `${config.apiUrl}/api/v1/preview/${encodeURIComponent(slug)}?token=${encodeURIComponent(token)}`
49
+ );
50
+ if (!res.ok) return new Response("Invalid or expired token", { status: 401 });
51
+ context.cookies.set(config.draftCookie, await signValue(token, secret), {
52
+ httpOnly: true,
53
+ sameSite: "lax",
54
+ secure: url.protocol === "https:",
55
+ path: "/",
56
+ maxAge: 60 * 60 * 24
57
+ // 24h, matching the preview-token lifetime
58
+ });
59
+ return context.redirect(redirectTo, 302);
60
+ };
61
+ export {
62
+ GET
63
+ };
64
+ //# sourceMappingURL=draft-enable.js.map
@@ -0,0 +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, 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(redirectTo, 302);\n};\n","/**\n * Runtime for @betttercms/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 \"@betttercms/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@betttercms/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/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAQA,OAAO,YAAY;;;ACAnB,SAAS,oBAA8C;AA0EvD,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAiBO,SAAS,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;;;ADxHO,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,YAAY,GAAG;AACzC;","names":[]}
@@ -0,0 +1,57 @@
1
+ import { BetterCMSReadClient } from '@betttercms/sdk';
2
+ import { DeliveryEntry, DeliveryList, DeliveryPage } from '@betttercms/types';
3
+
4
+ /**
5
+ * Runtime for @betttercms/astro — the code the `bettercms:client` virtual
6
+ * module binds to. Provides a published client, per-request draft client
7
+ * selection, typed loaders, and signed-cookie helpers for draft mode.
8
+ *
9
+ * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web
10
+ * Crypto + global fetch only).
11
+ */
12
+
13
+ interface RuntimeConfig {
14
+ apiUrl: string;
15
+ workspace: string;
16
+ projectId?: string;
17
+ /** Server-only delivery key (a `content:read:draft` key for drafts). */
18
+ apiKey?: string;
19
+ }
20
+ /** Per-request draft state, set on `Astro.locals.bcms` by the middleware. */
21
+ interface BcmsLocals {
22
+ draft: boolean;
23
+ token?: string;
24
+ }
25
+ interface HasLocals {
26
+ locals: {
27
+ bcms?: BcmsLocals;
28
+ };
29
+ }
30
+ interface BetterCMSRuntime {
31
+ /** Published-perspective client (use for SSG / non-draft reads). */
32
+ client: BetterCMSReadClient;
33
+ /** Pick the right client for a request (draft client when draft mode is on). */
34
+ getClient(astro?: HasLocals): BetterCMSReadClient;
35
+ loadEntry<T = Record<string, unknown>>(astro: HasLocals, slug: string, opts?: Parameters<BetterCMSReadClient["getEntry"]>[1]): Promise<DeliveryEntry<T>>;
36
+ loadEntries<T = Record<string, unknown>>(astro: HasLocals, opts?: Parameters<BetterCMSReadClient["listEntries"]>[0]): Promise<DeliveryList<DeliveryEntry<T>>>;
37
+ loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;
38
+ loadForms(astro: HasLocals, opts?: Parameters<BetterCMSReadClient["listForms"]>[0]): Promise<Awaited<ReturnType<BetterCMSReadClient["listForms"]>>>;
39
+ }
40
+ declare function createRuntime(config: RuntimeConfig): BetterCMSRuntime;
41
+ /**
42
+ * Build the live SSE URL for `<BcmsLive>`. The key is a query param because
43
+ * browser EventSource cannot send headers — use a content:read key.
44
+ */
45
+ declare function liveSrc(opts: {
46
+ apiUrl: string;
47
+ workspace: string;
48
+ apiKey: string;
49
+ }): string;
50
+ /** Sign a value as `value.sig` for tamper-evident cookie storage. */
51
+ declare function signValue(value: string, secret: string): Promise<string>;
52
+ /** Verify a signed value, returning the original value or null if tampered. */
53
+ declare function verifyValue(signed: string, secret: string): Promise<string | null>;
54
+ /** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */
55
+ declare function decodeTokenSlug(token: string): string | null;
56
+
57
+ export { type BcmsLocals, type BetterCMSRuntime, type RuntimeConfig, createRuntime, decodeTokenSlug, liveSrc, signValue, verifyValue };
@@ -0,0 +1,73 @@
1
+ // src/runtime.ts
2
+ import { createClient } from "@betttercms/sdk";
3
+ function createRuntime(config) {
4
+ const published = createClient({ ...config, perspective: "published" });
5
+ const getClient = (astro) => {
6
+ const bcms = astro?.locals?.bcms;
7
+ if (bcms?.draft) {
8
+ return createClient({ ...config, perspective: "drafts", previewToken: bcms.token });
9
+ }
10
+ return published;
11
+ };
12
+ return {
13
+ client: published,
14
+ getClient,
15
+ loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),
16
+ loadEntries: (astro, opts) => getClient(astro).listEntries(opts),
17
+ loadPage: (astro, slug) => getClient(astro).getPage(slug),
18
+ loadForms: (astro, opts) => getClient(astro).listForms(opts)
19
+ };
20
+ }
21
+ function liveSrc(opts) {
22
+ const base = opts.apiUrl.replace(/\/+$/, "");
23
+ return `${base}/api/v1/delivery/${opts.workspace}/live?key=${encodeURIComponent(opts.apiKey)}`;
24
+ }
25
+ var enc = new TextEncoder();
26
+ function base64url(bytes) {
27
+ const b = btoa(String.fromCharCode(...new Uint8Array(bytes)));
28
+ return b.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
29
+ }
30
+ async function hmac(value, secret) {
31
+ const key = await crypto.subtle.importKey(
32
+ "raw",
33
+ enc.encode(secret),
34
+ { name: "HMAC", hash: "SHA-256" },
35
+ false,
36
+ ["sign"]
37
+ );
38
+ return base64url(await crypto.subtle.sign("HMAC", key, enc.encode(value)));
39
+ }
40
+ async function signValue(value, secret) {
41
+ return `${value}.${await hmac(value, secret)}`;
42
+ }
43
+ async function verifyValue(signed, secret) {
44
+ const idx = signed.lastIndexOf(".");
45
+ if (idx < 0) return null;
46
+ const value = signed.slice(0, idx);
47
+ const sig = signed.slice(idx + 1);
48
+ const expected = await hmac(value, secret);
49
+ if (sig.length !== expected.length) return null;
50
+ let diff = 0;
51
+ for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
52
+ return diff === 0 ? value : null;
53
+ }
54
+ function decodeTokenSlug(token) {
55
+ try {
56
+ const part = token.split(".")[1];
57
+ if (!part) return null;
58
+ const json = JSON.parse(
59
+ atob(part.replace(/-/g, "+").replace(/_/g, "/"))
60
+ );
61
+ return json.entrySlug ?? null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ export {
67
+ createRuntime,
68
+ decodeTokenSlug,
69
+ liveSrc,
70
+ signValue,
71
+ verifyValue
72
+ };
73
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * Runtime for @betttercms/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 \"@betttercms/sdk\";\nimport type { DeliveryEntry, DeliveryList, DeliveryPage } from \"@betttercms/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/** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */\nexport function decodeTokenSlug(token: string): string | null {\n try {\n const part = token.split(\".\")[1];\n if (!part) return null;\n const json = JSON.parse(\n atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\")),\n ) as { entrySlug?: string };\n return json.entrySlug ?? null;\n } catch {\n return null;\n }\n}\n"],"mappings":";AAQA,SAAS,oBAA8C;AA0ChD,SAAS,cAAc,QAAyC;AACrE,QAAM,YAAY,aAAa,EAAE,GAAG,QAAQ,aAAa,YAAY,CAAC;AAEtE,QAAM,YAAY,CAAC,UAA2C;AAC5D,UAAM,OAAO,OAAO,QAAQ;AAC5B,QAAI,MAAM,OAAO;AACf,aAAO,aAAa,EAAE,GAAG,QAAQ,aAAa,UAAU,cAAc,KAAK,MAAM,CAAC;AAAA,IACpF;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA,WAAW,CAAC,OAAO,MAAM,SAAS,UAAU,KAAK,EAAE,SAAS,MAAM,IAAI;AAAA,IACtE,aAAa,CAAC,OAAO,SAAS,UAAU,KAAK,EAAE,YAAY,IAAI;AAAA,IAC/D,UAAU,CAAC,OAAO,SAAS,UAAU,KAAK,EAAE,QAAQ,IAAI;AAAA,IACxD,WAAW,CAAC,OAAO,SAAS,UAAU,KAAK,EAAE,UAAU,IAAI;AAAA,EAC7D;AACF;AAMO,SAAS,QAAQ,MAAqE;AAC3F,QAAM,OAAO,KAAK,OAAO,QAAQ,QAAQ,EAAE;AAC3C,SAAO,GAAG,IAAI,oBAAoB,KAAK,SAAS,aAAa,mBAAmB,KAAK,MAAM,CAAC;AAC9F;AAIA,IAAM,MAAM,IAAI,YAAY;AAE5B,SAAS,UAAU,OAA4B;AAC7C,QAAM,IAAI,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,KAAK,CAAC,CAAC;AAC5D,SAAO,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACpE;AAEA,eAAe,KAAK,OAAe,QAAiC;AAClE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,SAAO,UAAU,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,KAAK,CAAC,CAAC;AAC3E;AAGA,eAAsB,UAAU,OAAe,QAAiC;AAC9E,SAAO,GAAG,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAC9C;AAGA,eAAsB,YAAY,QAAgB,QAAwC;AACxF,QAAM,MAAM,OAAO,YAAY,GAAG;AAClC,MAAI,MAAM,EAAG,QAAO;AACpB,QAAM,QAAQ,OAAO,MAAM,GAAG,GAAG;AACjC,QAAM,MAAM,OAAO,MAAM,MAAM,CAAC;AAChC,QAAM,WAAW,MAAM,KAAK,OAAO,MAAM;AAEzC,MAAI,IAAI,WAAW,SAAS,OAAQ,QAAO;AAC3C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,SAAQ,IAAI,WAAW,CAAC,IAAI,SAAS,WAAW,CAAC;AACtF,SAAO,SAAS,IAAI,QAAQ;AAC9B;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAClB,UAAM,OAAO,KAAK;AAAA,MAChB,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC;AAAA,IACjD;AACA,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
package/env.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /// <reference types="astro/client" />
2
+
3
+ /**
4
+ * Ambient types for the virtual modules registered by the `bettercms()`
5
+ * integration, plus the `Astro.locals.bcms` augmentation. Add this package to
6
+ * your project's `src/env.d.ts` with:
7
+ *
8
+ * /// <reference types="@betttercms/astro/env" />
9
+ */
10
+
11
+ declare module "bettercms:config" {
12
+ import type { PublicConfig } from "@betttercms/astro";
13
+ export const config: PublicConfig;
14
+ export default config;
15
+ }
16
+
17
+ declare module "bettercms:client" {
18
+ import type { BetterCMSRuntime } from "@betttercms/astro";
19
+ export const client: BetterCMSRuntime["client"];
20
+ export const getClient: BetterCMSRuntime["getClient"];
21
+ export const loadEntry: BetterCMSRuntime["loadEntry"];
22
+ export const loadEntries: BetterCMSRuntime["loadEntries"];
23
+ export const loadPage: BetterCMSRuntime["loadPage"];
24
+ export const loadForms: BetterCMSRuntime["loadForms"];
25
+ const runtime: BetterCMSRuntime;
26
+ export default runtime;
27
+ }
28
+
29
+ declare namespace App {
30
+ interface Locals {
31
+ bcms?: import("@betttercms/astro").BcmsLocals;
32
+ }
33
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@betttercms/astro",
3
+ "version": "0.1.0",
4
+ "type": "module",
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
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "sideEffects": false,
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./runtime": {
17
+ "types": "./dist/runtime.d.ts",
18
+ "import": "./dist/runtime.js",
19
+ "default": "./dist/runtime.js"
20
+ },
21
+ "./middleware": {
22
+ "types": "./dist/middleware-entry.d.ts",
23
+ "import": "./dist/middleware-entry.js",
24
+ "default": "./dist/middleware-entry.js"
25
+ },
26
+ "./draft-enable": "./dist/routes/draft-enable.js",
27
+ "./draft-disable": "./dist/routes/draft-disable.js",
28
+ "./components/BcmsBlocks.astro": "./components/BcmsBlocks.astro",
29
+ "./components/BcmsForm.astro": "./components/BcmsForm.astro",
30
+ "./components/BcmsImage.astro": "./components/BcmsImage.astro",
31
+ "./components/BcmsLive.astro": "./components/BcmsLive.astro",
32
+ "./components/BcmsVisualEditing.astro": "./components/BcmsVisualEditing.astro",
33
+ "./env": "./env.d.ts"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "components",
38
+ "env.d.ts",
39
+ "README.md"
40
+ ],
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit",
43
+ "build": "tsup",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest"
46
+ },
47
+ "dependencies": {
48
+ "@betttercms/image-url": "^0.1.0",
49
+ "@betttercms/sdk": "^1.3.0",
50
+ "@betttercms/types": "^1.2.0"
51
+ },
52
+ "peerDependencies": {
53
+ "astro": ">=4"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^20",
57
+ "astro": "^5.0.0",
58
+ "tsup": "^8.5.1",
59
+ "typescript": "^5",
60
+ "vitest": "^4.1.4"
61
+ },
62
+ "publishConfig": {
63
+ "registry": "https://registry.npmjs.org/",
64
+ "access": "public"
65
+ }
66
+ }