@bettercms-ai/astro 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,9 +112,58 @@ or on-demand-revalidation hook.
112
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
+ | `@bettercms-ai/astro/components/BcmsLiveBlocks.astro` | `BcmsBlocks` as a hydrated island that lets the Visual Editor re-render your DRAFTS with your own components and CSS. See below. |
115
116
 
116
117
  Markup is class-driven and unstyled — you own the CSS.
117
118
 
119
+ ## Live editing on a static site — `<BcmsLiveBlocks>`
120
+
121
+ When the Visual Editor frames a static build it can only show what was built, so editing a
122
+ draft falls back to the platform's approximate renderer and the canvas stops looking like your
123
+ site. `<BcmsLiveBlocks>` fixes that for pages whose body is a block tree: it renders the same
124
+ markup `<BcmsBlocks>` does, but as a `client:load` island that accepts draft documents from the
125
+ editor over `postMessage` (same-origin, parent frame only, and only after the editor's own
126
+ handshake — a third-party page that iframes your site is on a foreign origin and gets nothing).
127
+ Nothing is fetched by the page and no credentials enter it.
128
+
129
+ It needs a JSX renderer, which a component cannot add for you:
130
+
131
+ ```sh
132
+ npm i @astrojs/preact preact
133
+ ```
134
+
135
+ ```js
136
+ // astro.config.mjs
137
+ import preact from "@astrojs/preact";
138
+ export default defineConfig({ integrations: [bettercms(), preact({ compat: true })] });
139
+ ```
140
+
141
+ ```astro
142
+ ---
143
+ import BcmsLiveBlocks from "@bettercms-ai/astro/components/BcmsLiveBlocks.astro";
144
+ const page = await getPage(Astro.params.slug);
145
+ const { forms } = await readForms();
146
+ ---
147
+ <BcmsLiveBlocks slug={page.slug} blocks={page.blocks} forms={forms} />
148
+ ```
149
+
150
+ `compat: true` is required — the island reuses the React `<BcmsDraftBridge>` from
151
+ `@bettercms-ai/next` so both SDKs speak one protocol. Roughly 10 kB of Preact plus the block
152
+ renderer; `@astrojs/react` works too if your project already uses it.
153
+
154
+ ⚠️ **It emits the React class names, not this package's.** `<BcmsBlocks>` from
155
+ [`@bettercms-ai/next`](https://www.npmjs.com/package/@bettercms-ai/next) classes its output
156
+ `bcms-block bcms-block--text`, `bcms-button--primary`, `bcms-column`; `BcmsBlocks.astro` above
157
+ classes the same tree `bcms-text`, `bcms-btn-primary`, `bcms-cols`. They are NOT interchangeable: a
158
+ stylesheet written for the `.astro` grammar styles nothing on this island, so swapping a page over
159
+ unstyles it. Cover both spellings — the starters wrap them in one `:is()` selector for exactly this
160
+ reason — or take the class list from `@bettercms-ai/next`'s `src/blocks.tsx`.
161
+
162
+ **It only helps pages you render through `BcmsBlocks`.** A hand-written `.astro` layout, or
163
+ content pulled into your own components, is invisible to the bridge — for those, structural
164
+ draft preview needs a server-rendered draft route instead (`output: 'server'` + the draft-mode
165
+ routes below).
166
+
118
167
  ## Draft mode
119
168
 
120
169
  The integration injects `/api/bcms/draft/enable?token=<jwt>&redirect=/path` and
@@ -448,7 +448,12 @@ const pass = { forms, components, turnstileSiteKey, nested: true, fieldAddresses
448
448
  blocks={resolved.blocks}
449
449
  depth={depth + 1}
450
450
  seen={[...seen, resolved.id]}
451
- containerAttrs={blockAttrs}
451
+ containerAttrs={{
452
+ ...blockAttrs,
453
+ "data-bcms-component": resolved.id,
454
+ "data-bcms-variant": "base",
455
+ "data-bcms-instance": block.id,
456
+ }}
452
457
  {...pass}
453
458
  fieldAddresses={componentInstanceAddresses(block.id, resolved.props)}
454
459
  />
File without changes
File without changes
File without changes
@@ -49,4 +49,18 @@ const { src, tags } = Astro.props;
49
49
  } catch (e) {
50
50
  /* EventSource unsupported — no live refresh */
51
51
  }
52
+
53
+ // Soft refresh for the BetterCMS canvas. The editor knows which of its writes are
54
+ // STRUCTURAL and asks for a re-render only for those; the ack is what tells it this page
55
+ // answered, so it does not remount the iframe (which throws away an inline text session).
56
+ // Same acceptance predicate as the Next draft bridge: own origin, and the parent frame.
57
+ if (window.parent !== window) {
58
+ window.addEventListener("message", (e) => {
59
+ if (e.origin !== location.origin || e.source !== window.parent) return;
60
+ if (e.data?.type !== "bcms:refresh") return;
61
+ // Ack BEFORE reloading — after it, this frame is gone and the editor has timed out.
62
+ window.parent.postMessage({ type: "bcms:refreshed" }, location.origin);
63
+ window.location.reload();
64
+ });
65
+ }
52
66
  </script>
@@ -0,0 +1,72 @@
1
+ ---
2
+ /**
3
+ * <BcmsLiveBlocks> — <BcmsBlocks>, but as a hydrated island that speaks the visual editor's
4
+ * draft bridge (E5 / FLO-1178).
5
+ *
6
+ * WHY IT IS NOT A .astro RENDERER. An .astro component is server-only: once the page is built
7
+ * there is nothing on the client to hand a new document to, so an Astro site can only show the
8
+ * editor a STATIC build and the canvas falls back to the platform's approximate renderer for
9
+ * every draft. The bridge needs a live component tree, and the only one that already exists,
10
+ * is at parity with this package, and speaks the protocol is <BcmsDraftBridge> from
11
+ * @bettercms-ai/next — which imports nothing from `next/*` (React and @bettercms-ai/* only),
12
+ * so it mounts here unchanged. Reused rather than reimplemented: one protocol, one twin to
13
+ * keep in step instead of two.
14
+ *
15
+ * RUNTIME: Preact + `compat`, verified by the P2-1 spike (see ../SPIKE.md — SSR and hydration
16
+ * of a heading + PortableText richtext + BcmsForm tree, both clean). The cost is real and worth
17
+ * naming: preact plus its compat aliases on the island (~10 kB of runtime, so `react` itself is
18
+ * never bundled even though @bettercms-ai/ui declares React peers), and `@bettercms-ai/next` as a
19
+ * dependency of this package.
20
+ *
21
+ * 🔴 THE CLASS NAMES ARE THE REACT GRAMMAR, NOT THIS PACKAGE'S. <BcmsBlocks> in @bettercms-ai/next
22
+ * emits `bcms-block bcms-block--text`, `bcms-button--primary`, `bcms-column`; BcmsBlocks.astro
23
+ * emits `bcms-text`, `bcms-btn-primary`, `bcms-cols`. Same markup shape, different hooks — a
24
+ * stylesheet written against the .astro grammar styles NOTHING here, and swapping a page from
25
+ * <BcmsBlocks> to <BcmsLiveBlocks> unstyles it. Either cover both spellings (the starters use
26
+ * `:is()` for exactly this) or read the React classes off @bettercms-ai/next's src/blocks.tsx.
27
+ *
28
+ * The consumer supplies the renderer, because an integration cannot be added from a component:
29
+ *
30
+ * // astro.config.mjs
31
+ * import preact from "@astrojs/preact";
32
+ * export default defineConfig({ integrations: [bettercms(), preact({ compat: true })] });
33
+ *
34
+ * ---
35
+ * import BcmsLiveBlocks from "@bettercms-ai/astro/components/BcmsLiveBlocks.astro";
36
+ * const page = await getPage(Astro.params.slug);
37
+ * ---
38
+ * <BcmsLiveBlocks slug={page.slug} blocks={page.blocks} forms={forms} />
39
+ *
40
+ * Standalone the island renders exactly what <BcmsBlocks> renders (inside the
41
+ * `data-bcms-canvas="bridge"` marker <div> that <BcmsDraftBridge> emits); the bridge only
42
+ * wakes when the editor frames the page and pushes a draft. This only helps pages whose body
43
+ * IS a block tree — a hand-written .astro layout is unreachable from here by construction.
44
+ */
45
+ import { BcmsDraftBridge } from "@bettercms-ai/next/draft-bridge";
46
+ import type { ContentBlock } from "@bettercms-ai/types";
47
+ import type { DeliveryComponent, DeliveryForm } from "@bettercms-ai/sdk";
48
+
49
+ interface Props {
50
+ /** The page's slug. A pushed draft is applied only when its slug matches. */
51
+ slug: string;
52
+ blocks: ContentBlock[];
53
+ forms?: DeliveryForm[];
54
+ components?: DeliveryComponent[];
55
+ turnstileSiteKey?: string;
56
+ apiBase?: string;
57
+ class?: string;
58
+ }
59
+
60
+ const { slug, blocks, forms, components, turnstileSiteKey, apiBase, class: className } = Astro.props;
61
+ ---
62
+
63
+ <BcmsDraftBridge
64
+ client:load
65
+ slug={slug}
66
+ blocks={blocks}
67
+ forms={forms}
68
+ components={components}
69
+ turnstileSiteKey={turnstileSiteKey}
70
+ apiBase={apiBase}
71
+ className={className}
72
+ />
File without changes
File without changes
package/dist/index.js CHANGED
@@ -66,6 +66,7 @@ function clientModule(cfg, buildCache) {
66
66
  `export const loadEntry = runtime.loadEntry;`,
67
67
  `export const loadEntries = runtime.loadEntries;`,
68
68
  `export const loadPage = runtime.loadPage;`,
69
+ `export const loadComponents = runtime.loadComponents;`,
69
70
  `export const loadForms = runtime.loadForms;`,
70
71
  `export default runtime;`
71
72
  ].join("\n");
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 * 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, buildCache: boolean): 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 ` buildCache: ${JSON.stringify(buildCache)},`,\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, buildCache: () => boolean) {\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, buildCache());\n return null;\n },\n };\n}\n\nexport function bettercms(options: BetterCMSAstroOptions = {}): AstroIntegration {\n /**\n * Whether identical delivery GETs may be collapsed for the life of the process.\n *\n * Read lazily by the virtual module (see `astro:config:done` below) because only the\n * FINAL config settles `output` and `adapter` — another integration can add an adapter\n * after `astro:config:setup` runs.\n */\n const buildCache = { on: false, command: \"\", resolved: false };\n /** Set in `astro:config:done`, re-run in `astro:build:start` — see both hooks below. */\n let decide: (() => void) | undefined;\n\n return {\n name: \"@bettercms-ai/astro\",\n hooks: {\n \"astro:config:setup\": ({ command, updateConfig, addMiddleware, injectRoute, logger }) => {\n const cfg = resolveConfig(options);\n buildCache.command = command;\n\n updateConfig({ vite: { plugins: [virtualPlugin(cfg, () => buildCache.on)] } });\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 * Decide whether the build-time request cache is safe, on the FINAL config.\n *\n * A fully static build renders every route from one published dataset, so identical\n * delivery GETs can collapse to a single round-trip. Without that a large site\n * re-fetched its nav/settings/collections once PER ROUTE and exhausted the delivery\n * rate limit mid-build — a 429 the build could not avoid.\n *\n * `command === \"build\"` is NOT sufficient to detect that, and getting this wrong is\n * far worse than the problem it solves: Astro also reports `command === \"build\"` when\n * it compiles `output: \"server\"`, whose bundle then runs as a LONG-LIVED process. A\n * module-lifetime cache there would pin the first response for every URL and serve it\n * to unrelated visitors until the process restarted — permanent stale content on a\n * production site.\n *\n * `buildOutput` (Astro 5) is Astro's OWN answer to \"will this emit a server?\", so it\n * is what we trust when present. Inferring it from `config.adapter` is not equivalent:\n * `setAdapter()` is documented as being called from inside `astro:config:done`, so an\n * adapter integration whose hook runs after this one would still read as absent here.\n *\n * On Astro 4 there is no `buildOutput`, and there `output: \"static\"` genuinely\n * prerenders everything — on-demand rendering required `\"server\"` or `\"hybrid\"`, both\n * of which fail this check. The adapter test stays as a second line of defence.\n */\n \"astro:config:done\": ({ config, logger, ...rest }) => {\n // Astro >= 5 only; undefined on 4, hence the fallback rather than a bare read.\n const buildOutput = (rest as { buildOutput?: \"static\" | \"server\" }).buildOutput;\n const staticOutput =\n buildOutput !== undefined\n ? buildOutput === \"static\"\n : config.output === \"static\" && !config.adapter;\n\n buildCache.on = buildCache.command === \"build\" && staticOutput;\n // Re-checked in astro:build:start below; only announce once it is final.\n decide = () => {\n const late = (rest as { buildOutput?: \"static\" | \"server\" }).buildOutput;\n const stillStatic =\n late !== undefined ? late === \"static\" : config.output === \"static\" && !config.adapter;\n buildCache.on = buildCache.command === \"build\" && stillStatic;\n buildCache.resolved = true;\n if (buildCache.on) {\n logger.info(\"static build — identical content reads will be fetched once\");\n }\n };\n },\n\n /**\n * Last word on the cache, and the reason this is not decided in `astro:config:done`\n * alone: Astro runs every integration's `config:done` in order, and `setAdapter()` is\n * called FROM that hook. An adapter registered by an integration ordered after this one\n * would flip the project to on-demand rendering *after* we had already recorded\n * \"static\" — and the stale `true` would ship a module-lifetime cache into a\n * long-lived server. `astro:build:start` runs after all of them and before Vite loads\n * the virtual module, so re-reading here is the value that is actually true.\n *\n * It never fires for `dev` or `preview`, which is exactly right: both must stay\n * uncached, and `buildCache.on` is only ever set to true under `command === \"build\"`.\n */\n \"astro:build:start\": () => {\n decide?.();\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 { DeliveredLayout, 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadLayout: (astro) => getClient(astro).getLayout(),\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 DeliveredLayout,\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, buildCache: boolean): 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 ` buildCache: ${JSON.stringify(buildCache)},`,\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 loadComponents = runtime.loadComponents;`,\n `export const loadForms = runtime.loadForms;`,\n `export default runtime;`,\n ].join(\"\\n\");\n}\n\nfunction virtualPlugin(cfg: ResolvedConfig, buildCache: () => boolean) {\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, buildCache());\n return null;\n },\n };\n}\n\nexport function bettercms(options: BetterCMSAstroOptions = {}): AstroIntegration {\n /**\n * Whether identical delivery GETs may be collapsed for the life of the process.\n *\n * Read lazily by the virtual module (see `astro:config:done` below) because only the\n * FINAL config settles `output` and `adapter` — another integration can add an adapter\n * after `astro:config:setup` runs.\n */\n const buildCache = { on: false, command: \"\", resolved: false };\n /** Set in `astro:config:done`, re-run in `astro:build:start` — see both hooks below. */\n let decide: (() => void) | undefined;\n\n return {\n name: \"@bettercms-ai/astro\",\n hooks: {\n \"astro:config:setup\": ({ command, updateConfig, addMiddleware, injectRoute, logger }) => {\n const cfg = resolveConfig(options);\n buildCache.command = command;\n\n updateConfig({ vite: { plugins: [virtualPlugin(cfg, () => buildCache.on)] } });\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 * Decide whether the build-time request cache is safe, on the FINAL config.\n *\n * A fully static build renders every route from one published dataset, so identical\n * delivery GETs can collapse to a single round-trip. Without that a large site\n * re-fetched its nav/settings/collections once PER ROUTE and exhausted the delivery\n * rate limit mid-build — a 429 the build could not avoid.\n *\n * `command === \"build\"` is NOT sufficient to detect that, and getting this wrong is\n * far worse than the problem it solves: Astro also reports `command === \"build\"` when\n * it compiles `output: \"server\"`, whose bundle then runs as a LONG-LIVED process. A\n * module-lifetime cache there would pin the first response for every URL and serve it\n * to unrelated visitors until the process restarted — permanent stale content on a\n * production site.\n *\n * `buildOutput` (Astro 5) is Astro's OWN answer to \"will this emit a server?\", so it\n * is what we trust when present. Inferring it from `config.adapter` is not equivalent:\n * `setAdapter()` is documented as being called from inside `astro:config:done`, so an\n * adapter integration whose hook runs after this one would still read as absent here.\n *\n * On Astro 4 there is no `buildOutput`, and there `output: \"static\"` genuinely\n * prerenders everything — on-demand rendering required `\"server\"` or `\"hybrid\"`, both\n * of which fail this check. The adapter test stays as a second line of defence.\n */\n \"astro:config:done\": ({ config, logger, ...rest }) => {\n // Astro >= 5 only; undefined on 4, hence the fallback rather than a bare read.\n const buildOutput = (rest as { buildOutput?: \"static\" | \"server\" }).buildOutput;\n const staticOutput =\n buildOutput !== undefined\n ? buildOutput === \"static\"\n : config.output === \"static\" && !config.adapter;\n\n buildCache.on = buildCache.command === \"build\" && staticOutput;\n // Re-checked in astro:build:start below; only announce once it is final.\n decide = () => {\n const late = (rest as { buildOutput?: \"static\" | \"server\" }).buildOutput;\n const stillStatic =\n late !== undefined ? late === \"static\" : config.output === \"static\" && !config.adapter;\n buildCache.on = buildCache.command === \"build\" && stillStatic;\n buildCache.resolved = true;\n if (buildCache.on) {\n logger.info(\"static build — identical content reads will be fetched once\");\n }\n };\n },\n\n /**\n * Last word on the cache, and the reason this is not decided in `astro:config:done`\n * alone: Astro runs every integration's `config:done` in order, and `setAdapter()` is\n * called FROM that hook. An adapter registered by an integration ordered after this one\n * would flip the project to on-demand rendering *after* we had already recorded\n * \"static\" — and the stale `true` would ship a module-lifetime cache into a\n * long-lived server. `astro:build:start` runs after all of them and before Vite loads\n * the virtual module, so re-reading here is the value that is actually true.\n *\n * It never fires for `dev` or `preview`, which is exactly right: both must stay\n * uncached, and `buildCache.on` is only ever set to true under `command === \"build\"`.\n */\n \"astro:build:start\": () => {\n decide?.();\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 {\n DeliveredLayout,\n DeliveryComponent,\n DeliveryEntry,\n DeliveryList,\n DeliveryPage,\n} 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 /** Component definitions for `<BcmsBlocks components={…}>` — draft-side in draft mode. */\n loadComponents(astro: HasLocals): Promise<DeliveryComponent[]>;\n loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadComponents: (astro) => getClient(astro).listComponents(),\n loadLayout: (astro) => getClient(astro).getLayout(),\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/**\n * The draft-cookie signing secret, `process.env` FIRST.\n *\n * Vite may INLINE `import.meta.env.BCMS_DRAFT_SECRET` at build time, and the value present\n * when the customer's site is built is not the value the hosting container is given at start\n * — BetterCMS derives it per project and injects it as a real env var. Reading the runtime\n * env first is what makes managed hosting work without asking anyone to rebuild. The\n * `import.meta.env` read stays as the fallback for `astro dev` and for adapters with no\n * `process` (guarded, since touching `import.meta.env` outside Vite throws).\n */\nexport function draftSecret(): string | undefined {\n const fromProcess =\n typeof process !== \"undefined\" ? (process.env?.BCMS_DRAFT_SECRET as string | undefined) : undefined;\n if (fromProcess) return fromProcess;\n try {\n return import.meta.env?.BCMS_DRAFT_SECRET as string | undefined;\n } catch {\n return undefined;\n }\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 DeliveredLayout,\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"]}
@@ -5,6 +5,9 @@ import { MiddlewareHandler } from 'astro';
5
5
  *
6
6
  * Reads the signed draft cookie, verifies it with `BCMS_DRAFT_SECRET`, and
7
7
  * publishes the result on `Astro.locals.bcms` so loaders pick the draft client.
8
+ * In draft mode it also stamps `<html data-bcms-draft="1">` on the response — the
9
+ * only thing the BetterCMS canvas trusts as proof that the frame it loaded really is
10
+ * this site rendering a draft, rather than the published page it falls back to.
8
11
  */
9
12
 
10
13
  declare const onRequest: MiddlewareHandler;
@@ -29,18 +29,38 @@ async function verifyValue(signed, secret) {
29
29
  for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
30
30
  return diff === 0 ? value : null;
31
31
  }
32
+ function draftSecret() {
33
+ const fromProcess = typeof process !== "undefined" ? process.env?.BCMS_DRAFT_SECRET : void 0;
34
+ if (fromProcess) return fromProcess;
35
+ try {
36
+ return import.meta.env?.BCMS_DRAFT_SECRET;
37
+ } catch {
38
+ return void 0;
39
+ }
40
+ }
32
41
 
33
42
  // src/middleware-entry.ts
34
43
  var onRequest = async (context, next) => {
35
44
  const cookie = context.cookies.get(config.draftCookie)?.value;
36
- const secret = import.meta.env.BCMS_DRAFT_SECRET;
45
+ const secret = draftSecret();
37
46
  let locals = { draft: false };
38
47
  if (cookie && secret) {
39
48
  const token = await verifyValue(cookie, secret);
40
49
  if (token) locals = { draft: true, token };
41
50
  }
42
51
  context.locals.bcms = locals;
43
- return next();
52
+ const response = await next();
53
+ if (!locals.draft || !response.headers.get("content-type")?.includes("text/html")) {
54
+ return response;
55
+ }
56
+ const html = await response.text();
57
+ const headers = new Headers(response.headers);
58
+ headers.delete("content-length");
59
+ return new Response(html.replace("<html", '<html data-bcms-draft="1"'), {
60
+ status: response.status,
61
+ statusText: response.statusText,
62
+ headers
63
+ });
44
64
  };
45
65
  export {
46
66
  onRequest
@@ -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 { DeliveredLayout, 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadLayout: (astro) => getClient(astro).getLayout(),\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;AA4GvD,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;;;AD7IO,IAAM,YAA+B,OAAO,SAAS,SAAS;AACnE,QAAM,SAAS,QAAQ,QAAQ,IAAI,OAAO,WAAW,GAAG;AACxD,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,SAAqB,EAAE,OAAO,MAAM;AACxC,MAAI,UAAU,QAAQ;AACpB,UAAM,QAAQ,MAAM,YAAY,QAAQ,MAAM;AAC9C,QAAI,MAAO,UAAS,EAAE,OAAO,MAAM,MAAM;AAAA,EAC3C;AACA,UAAQ,OAAO,OAAO;AAEtB,SAAO,KAAK;AACd;","names":[]}
1
+ {"version":3,"sources":["../src/middleware-entry.ts","../src/runtime.ts"],"sourcesContent":["/**\n * Draft-mode middleware (registered via `addMiddleware`).\n *\n * Reads the signed draft cookie, verifies it with `BCMS_DRAFT_SECRET`, and\n * publishes the result on `Astro.locals.bcms` so loaders pick the draft client.\n * In draft mode it also stamps `<html data-bcms-draft=\"1\">` on the response — the\n * only thing the BetterCMS canvas trusts as proof that the frame it loaded really is\n * this site rendering a draft, rather than the published page it falls back to.\n */\nimport type { MiddlewareHandler } from \"astro\";\nimport config from \"bettercms:config\";\nimport { draftSecret, 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 = draftSecret();\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 const response = await next();\n // Untouched off the draft path: reading the body would buffer every response on the site,\n // including streamed and non-HTML ones, for a marker nobody is looking for.\n if (!locals.draft || !response.headers.get(\"content-type\")?.includes(\"text/html\")) {\n return response;\n }\n const html = await response.text();\n // The rewrite is 20 bytes longer than what the upstream handler measured, so a\n // `content-length` carried over from it truncates the body mid-tag. Drop it and let the\n // server recompute; every other header (cookies, cache, CSP) must survive untouched.\n const headers = new Headers(response.headers);\n headers.delete(\"content-length\");\n return new Response(html.replace(\"<html\", '<html data-bcms-draft=\"1\"'), {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\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 {\n DeliveredLayout,\n DeliveryComponent,\n DeliveryEntry,\n DeliveryList,\n DeliveryPage,\n} 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 /** Component definitions for `<BcmsBlocks components={…}>` — draft-side in draft mode. */\n loadComponents(astro: HasLocals): Promise<DeliveryComponent[]>;\n loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadComponents: (astro) => getClient(astro).listComponents(),\n loadLayout: (astro) => getClient(astro).getLayout(),\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/**\n * The draft-cookie signing secret, `process.env` FIRST.\n *\n * Vite may INLINE `import.meta.env.BCMS_DRAFT_SECRET` at build time, and the value present\n * when the customer's site is built is not the value the hosting container is given at start\n * — BetterCMS derives it per project and injects it as a real env var. Reading the runtime\n * env first is what makes managed hosting work without asking anyone to rebuild. The\n * `import.meta.env` read stays as the fallback for `astro dev` and for adapters with no\n * `process` (guarded, since touching `import.meta.env` outside Vite throws).\n */\nexport function draftSecret(): string | undefined {\n const fromProcess =\n typeof process !== \"undefined\" ? (process.env?.BCMS_DRAFT_SECRET as string | undefined) : undefined;\n if (fromProcess) return fromProcess;\n try {\n return import.meta.env?.BCMS_DRAFT_SECRET as string | undefined;\n } catch {\n return undefined;\n }\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":";AAUA,OAAO,YAAY;;;ACFnB,SAAS,oBAA8C;AAqHvD,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;AAYO,SAAS,cAAkC;AAChD,QAAM,cACJ,OAAO,YAAY,cAAe,QAAQ,KAAK,oBAA2C;AAC5F,MAAI,YAAa,QAAO;AACxB,MAAI;AACF,WAAO,YAAY,KAAK;AAAA,EAC1B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;ADxKO,IAAM,YAA+B,OAAO,SAAS,SAAS;AACnE,QAAM,SAAS,QAAQ,QAAQ,IAAI,OAAO,WAAW,GAAG;AACxD,QAAM,SAAS,YAAY;AAE3B,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,QAAM,WAAW,MAAM,KAAK;AAG5B,MAAI,CAAC,OAAO,SAAS,CAAC,SAAS,QAAQ,IAAI,cAAc,GAAG,SAAS,WAAW,GAAG;AACjF,WAAO;AAAA,EACT;AACA,QAAM,OAAO,MAAM,SAAS,KAAK;AAIjC,QAAM,UAAU,IAAI,QAAQ,SAAS,OAAO;AAC5C,UAAQ,OAAO,gBAAgB;AAC/B,SAAO,IAAI,SAAS,KAAK,QAAQ,SAAS,2BAA2B,GAAG;AAAA,IACtE,QAAQ,SAAS;AAAA,IACjB,YAAY,SAAS;AAAA,IACrB;AAAA,EACF,CAAC;AACH;","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 { DeliveredLayout, 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadLayout: (astro) => getClient(astro).getLayout(),\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;AA4GvD,IAAM,MAAM,IAAI,YAAY;AAsCrB,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;;;ADrJO,IAAM,MAAgB,OAAO,YAAY;AAC9C,QAAM,aAAa,IAAI,IAAI,QAAQ,QAAQ,GAAG,EAAE,aAAa,IAAI,UAAU,KAAK;AAChF,UAAQ,QAAQ,OAAO,OAAO,aAAa,EAAE,MAAM,IAAI,CAAC;AACxD,SAAO,QAAQ,SAAS,iBAAiB,UAAU,GAAG,GAAG;AAC3D;","names":[]}
1
+ {"version":3,"sources":["../../src/routes/draft-disable.ts","../../src/runtime.ts"],"sourcesContent":["/**\n * GET /api/bcms/draft/disable?redirect=/path — clear the draft cookie.\n */\nimport type { APIRoute } from \"astro\";\nimport config from \"bettercms:config\";\nimport { safeRedirectPath } from \"../runtime.js\";\n\nexport const GET: APIRoute = async (context) => {\n const redirectTo = new URL(context.request.url).searchParams.get(\"redirect\") ?? \"/\";\n context.cookies.delete(config.draftCookie, { path: \"/\" });\n return context.redirect(safeRedirectPath(redirectTo), 302);\n};\n","/**\n * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual\n * module binds to. Provides a published client, per-request draft client\n * selection, typed loaders, and signed-cookie helpers for draft mode.\n *\n * Read-only; no I/O at import time. Safe in Node and edge runtimes (uses Web\n * Crypto + global fetch only).\n */\nimport { createClient, type BetterCMSReadClient } from \"@bettercms-ai/sdk\";\nimport type {\n DeliveredLayout,\n DeliveryComponent,\n DeliveryEntry,\n DeliveryList,\n DeliveryPage,\n} 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 /** Component definitions for `<BcmsBlocks components={…}>` — draft-side in draft mode. */\n loadComponents(astro: HasLocals): Promise<DeliveryComponent[]>;\n loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadComponents: (astro) => getClient(astro).listComponents(),\n loadLayout: (astro) => getClient(astro).getLayout(),\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/**\n * The draft-cookie signing secret, `process.env` FIRST.\n *\n * Vite may INLINE `import.meta.env.BCMS_DRAFT_SECRET` at build time, and the value present\n * when the customer's site is built is not the value the hosting container is given at start\n * — BetterCMS derives it per project and injects it as a real env var. Reading the runtime\n * env first is what makes managed hosting work without asking anyone to rebuild. The\n * `import.meta.env` read stays as the fallback for `astro dev` and for adapters with no\n * `process` (guarded, since touching `import.meta.env` outside Vite throws).\n */\nexport function draftSecret(): string | undefined {\n const fromProcess =\n typeof process !== \"undefined\" ? (process.env?.BCMS_DRAFT_SECRET as string | undefined) : undefined;\n if (fromProcess) return fromProcess;\n try {\n return import.meta.env?.BCMS_DRAFT_SECRET as string | undefined;\n } catch {\n return undefined;\n }\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;AAqHvD,IAAM,MAAM,IAAI,YAAY;AA2DrB,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;;;ADnLO,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":[]}
@@ -21,6 +21,15 @@ async function hmac(value, secret) {
21
21
  async function signValue(value, secret) {
22
22
  return `${value}.${await hmac(value, secret)}`;
23
23
  }
24
+ function draftSecret() {
25
+ const fromProcess = typeof process !== "undefined" ? process.env?.BCMS_DRAFT_SECRET : void 0;
26
+ if (fromProcess) return fromProcess;
27
+ try {
28
+ return import.meta.env?.BCMS_DRAFT_SECRET;
29
+ } catch {
30
+ return void 0;
31
+ }
32
+ }
24
33
  function safeRedirectPath(to) {
25
34
  return to.startsWith("/") && !to.startsWith("//") ? to : "/";
26
35
  }
@@ -42,7 +51,7 @@ var GET = async (context) => {
42
51
  const url = new URL(context.request.url);
43
52
  const token = url.searchParams.get("token");
44
53
  const redirectTo = url.searchParams.get("redirect") ?? "/";
45
- const secret = import.meta.env.BCMS_DRAFT_SECRET;
54
+ const secret = draftSecret();
46
55
  if (!secret) return new Response("BCMS_DRAFT_SECRET is not configured", { status: 500 });
47
56
  if (!token) return new Response("Missing token", { status: 400 });
48
57
  const slug = decodeTokenSlug(token);
@@ -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 { DeliveredLayout, 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadLayout: (astro) => getClient(astro).getLayout(),\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;AA4GvD,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;;;AD/JO,IAAM,MAAgB,OAAO,YAAY;AAC9C,QAAM,MAAM,IAAI,IAAI,QAAQ,QAAQ,GAAG;AACvC,QAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,QAAM,aAAa,IAAI,aAAa,IAAI,UAAU,KAAK;AACvD,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,CAAC,OAAQ,QAAO,IAAI,SAAS,uCAAuC,EAAE,QAAQ,IAAI,CAAC;AACvF,MAAI,CAAC,MAAO,QAAO,IAAI,SAAS,iBAAiB,EAAE,QAAQ,IAAI,CAAC;AAEhE,QAAM,OAAO,gBAAgB,KAAK;AAClC,MAAI,CAAC,KAAM,QAAO,IAAI,SAAS,mBAAmB,EAAE,QAAQ,IAAI,CAAC;AAEjE,QAAM,MAAM,MAAM;AAAA,IAChB,GAAG,OAAO,MAAM,mBAAmB,mBAAmB,IAAI,CAAC,UAAU,mBAAmB,KAAK,CAAC;AAAA,EAChG;AACA,MAAI,CAAC,IAAI,GAAI,QAAO,IAAI,SAAS,4BAA4B,EAAE,QAAQ,IAAI,CAAC;AAE5E,UAAQ,QAAQ,IAAI,OAAO,aAAa,MAAM,UAAU,OAAO,MAAM,GAAG;AAAA,IACtE,UAAU;AAAA,IACV,UAAU;AAAA,IACV,QAAQ,IAAI,aAAa;AAAA,IACzB,MAAM;AAAA,IACN,QAAQ,KAAK,KAAK;AAAA;AAAA,EACpB,CAAC;AAED,SAAO,QAAQ,SAAS,iBAAiB,UAAU,GAAG,GAAG;AAC3D;","names":[]}
1
+ {"version":3,"sources":["../../src/routes/draft-enable.ts","../../src/runtime.ts"],"sourcesContent":["/**\n * GET /api/bcms/draft/enable?token=<jwt>&redirect=/path\n *\n * Validates the preview token against the backend `/preview/:slug` endpoint\n * (the backend is the signature authority), then sets a signed draft cookie and\n * redirects. Requires `BCMS_DRAFT_SECRET` in the server env.\n */\nimport type { APIRoute } from \"astro\";\nimport config from \"bettercms:config\";\nimport { decodeTokenSlug, draftSecret, 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 = draftSecret();\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 {\n DeliveredLayout,\n DeliveryComponent,\n DeliveryEntry,\n DeliveryList,\n DeliveryPage,\n} 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 /** Component definitions for `<BcmsBlocks components={…}>` — draft-side in draft mode. */\n loadComponents(astro: HasLocals): Promise<DeliveryComponent[]>;\n loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadComponents: (astro) => getClient(astro).listComponents(),\n loadLayout: (astro) => getClient(astro).getLayout(),\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/**\n * The draft-cookie signing secret, `process.env` FIRST.\n *\n * Vite may INLINE `import.meta.env.BCMS_DRAFT_SECRET` at build time, and the value present\n * when the customer's site is built is not the value the hosting container is given at start\n * — BetterCMS derives it per project and injects it as a real env var. Reading the runtime\n * env first is what makes managed hosting work without asking anyone to rebuild. The\n * `import.meta.env` read stays as the fallback for `astro dev` and for adapters with no\n * `process` (guarded, since touching `import.meta.env` outside Vite throws).\n */\nexport function draftSecret(): string | undefined {\n const fromProcess =\n typeof process !== \"undefined\" ? (process.env?.BCMS_DRAFT_SECRET as string | undefined) : undefined;\n if (fromProcess) return fromProcess;\n try {\n return import.meta.env?.BCMS_DRAFT_SECRET as string | undefined;\n } catch {\n return undefined;\n }\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;AAqHvD,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;AA0BO,SAAS,cAAkC;AAChD,QAAM,cACJ,OAAO,YAAY,cAAe,QAAQ,KAAK,oBAA2C;AAC5F,MAAI,YAAa,QAAO;AACxB,MAAI;AACF,WAAO,YAAY,KAAK;AAAA,EAC1B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,SAAS,iBAAiB,IAAoB;AACnD,SAAO,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,WAAW,IAAI,IAAI,KAAK;AAC3D;AAGO,SAAS,gBAAgB,OAA8B;AAC5D,MAAI;AACF,UAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,QAAI,CAAC,KAAM,QAAO;AAClB,UAAM,OAAO,KAAK;AAAA,MAChB,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC;AAAA,IACjD;AACA,WAAO,KAAK,aAAa;AAAA,EAC3B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;AD7LO,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;AAE3B,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
@@ -1,5 +1,5 @@
1
1
  import { BetterCMSReadClient } from '@bettercms-ai/sdk';
2
- import { DeliveryEntry, DeliveryList, DeliveryPage, DeliveredLayout } from '@bettercms-ai/types';
2
+ import { DeliveryEntry, DeliveryList, DeliveryPage, DeliveryComponent, DeliveredLayout } from '@bettercms-ai/types';
3
3
 
4
4
  /**
5
5
  * Runtime for @bettercms-ai/astro — the code the `bettercms:client` virtual
@@ -43,6 +43,8 @@ interface BetterCMSRuntime {
43
43
  loadEntry<T = Record<string, unknown>>(astro: HasLocals, slug: string, opts?: Parameters<BetterCMSReadClient["getEntry"]>[1]): Promise<DeliveryEntry<T>>;
44
44
  loadEntries<T = Record<string, unknown>>(astro: HasLocals, opts?: Parameters<BetterCMSReadClient["listEntries"]>[0]): Promise<DeliveryList<DeliveryEntry<T>>>;
45
45
  loadPage(astro: HasLocals, slug: string): Promise<DeliveryPage | null>;
46
+ /** Component definitions for `<BcmsBlocks components={…}>` — draft-side in draft mode. */
47
+ loadComponents(astro: HasLocals): Promise<DeliveryComponent[]>;
46
48
  loadLayout(astro: HasLocals): Promise<DeliveredLayout | null>;
47
49
  loadForms(astro: HasLocals, opts?: Parameters<BetterCMSReadClient["listForms"]>[0]): Promise<Awaited<ReturnType<BetterCMSReadClient["listForms"]>>>;
48
50
  }
@@ -68,9 +70,20 @@ declare function liveSrc(opts: {
68
70
  declare function signValue(value: string, secret: string): Promise<string>;
69
71
  /** Verify a signed value, returning the original value or null if tampered. */
70
72
  declare function verifyValue(signed: string, secret: string): Promise<string | null>;
73
+ /**
74
+ * The draft-cookie signing secret, `process.env` FIRST.
75
+ *
76
+ * Vite may INLINE `import.meta.env.BCMS_DRAFT_SECRET` at build time, and the value present
77
+ * when the customer's site is built is not the value the hosting container is given at start
78
+ * — BetterCMS derives it per project and injects it as a real env var. Reading the runtime
79
+ * env first is what makes managed hosting work without asking anyone to rebuild. The
80
+ * `import.meta.env` read stays as the fallback for `astro dev` and for adapters with no
81
+ * `process` (guarded, since touching `import.meta.env` outside Vite throws).
82
+ */
83
+ declare function draftSecret(): string | undefined;
71
84
  /** Only same-origin relative paths. Blocks "//evil.com", "https://evil.com", etc. */
72
85
  declare function safeRedirectPath(to: string): string;
73
86
  /** Decode a preview-token JWT payload (UNVERIFIED) to read its `entrySlug`. */
74
87
  declare function decodeTokenSlug(token: string): string | null;
75
88
 
76
- export { type BcmsLocals, type BetterCMSRuntime, type RuntimeConfig, createRuntime, decodeTokenSlug, liveSrc, safeRedirectPath, signValue, verifyValue };
89
+ export { type BcmsLocals, type BetterCMSRuntime, type RuntimeConfig, createRuntime, decodeTokenSlug, draftSecret, liveSrc, safeRedirectPath, signValue, verifyValue };
package/dist/runtime.js CHANGED
@@ -21,6 +21,7 @@ function createRuntime(config) {
21
21
  loadEntry: (astro, slug, opts) => getClient(astro).getEntry(slug, opts),
22
22
  loadEntries: (astro, opts) => getClient(astro).listEntries(opts),
23
23
  loadPage: (astro, slug) => getClient(astro).getPage(slug),
24
+ loadComponents: (astro) => getClient(astro).listComponents(),
24
25
  loadLayout: (astro) => getClient(astro).getLayout(),
25
26
  loadForms: (astro, opts) => getClient(astro).listForms(opts)
26
27
  };
@@ -61,6 +62,15 @@ async function verifyValue(signed, secret) {
61
62
  for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
62
63
  return diff === 0 ? value : null;
63
64
  }
65
+ function draftSecret() {
66
+ const fromProcess = typeof process !== "undefined" ? process.env?.BCMS_DRAFT_SECRET : void 0;
67
+ if (fromProcess) return fromProcess;
68
+ try {
69
+ return import.meta.env?.BCMS_DRAFT_SECRET;
70
+ } catch {
71
+ return void 0;
72
+ }
73
+ }
64
74
  function safeRedirectPath(to) {
65
75
  return to.startsWith("/") && !to.startsWith("//") ? to : "/";
66
76
  }
@@ -79,6 +89,7 @@ function decodeTokenSlug(token) {
79
89
  export {
80
90
  createRuntime,
81
91
  decodeTokenSlug,
92
+ draftSecret,
82
93
  liveSrc,
83
94
  safeRedirectPath,
84
95
  signValue,
@@ -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 { DeliveredLayout, 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadLayout: (astro) => getClient(astro).getLayout(),\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":[]}
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 {\n DeliveredLayout,\n DeliveryComponent,\n DeliveryEntry,\n DeliveryList,\n DeliveryPage,\n} 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 * Collapse identical delivery GETs for the life of the process. Set by the integration\n * when Astro's `command === \"build\"` — a static build renders every route from one\n * published dataset, so a repeat read cannot legitimately differ.\n *\n * Never set for `dev` or SSR, where each request must see current content.\n */\n buildCache?: boolean;\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 /** Component definitions for `<BcmsBlocks components={…}>` — draft-side in draft mode. */\n loadComponents(astro: HasLocals): Promise<DeliveryComponent[]>;\n loadLayout(astro: HasLocals): Promise<DeliveredLayout | 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 { buildCache, ...clientConfig } = config;\n // One client for the whole build, so the cache below is shared across every route\n // rather than rebuilt per render.\n const published = createClient({ ...clientConfig, perspective: \"published\", cache: buildCache });\n\n const getClient = (astro?: HasLocals): BetterCMSReadClient => {\n const bcms = astro?.locals?.bcms;\n if (bcms?.draft) {\n // Explicitly uncached: a draft read exists to show the edit that just happened.\n return createClient({\n ...clientConfig,\n perspective: \"drafts\",\n previewToken: bcms.token,\n cache: false,\n });\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 loadComponents: (astro) => getClient(astro).listComponents(),\n loadLayout: (astro) => getClient(astro).getLayout(),\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/**\n * The draft-cookie signing secret, `process.env` FIRST.\n *\n * Vite may INLINE `import.meta.env.BCMS_DRAFT_SECRET` at build time, and the value present\n * when the customer's site is built is not the value the hosting container is given at start\n * — BetterCMS derives it per project and injects it as a real env var. Reading the runtime\n * env first is what makes managed hosting work without asking anyone to rebuild. The\n * `import.meta.env` read stays as the fallback for `astro dev` and for adapters with no\n * `process` (guarded, since touching `import.meta.env` outside Vite throws).\n */\nexport function draftSecret(): string | undefined {\n const fromProcess =\n typeof process !== \"undefined\" ? (process.env?.BCMS_DRAFT_SECRET as string | undefined) : undefined;\n if (fromProcess) return fromProcess;\n try {\n return import.meta.env?.BCMS_DRAFT_SECRET as string | undefined;\n } catch {\n return undefined;\n }\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/env.d.ts CHANGED
@@ -21,6 +21,7 @@ declare module "bettercms:client" {
21
21
  export const loadEntry: BetterCMSRuntime["loadEntry"];
22
22
  export const loadEntries: BetterCMSRuntime["loadEntries"];
23
23
  export const loadPage: BetterCMSRuntime["loadPage"];
24
+ export const loadComponents: BetterCMSRuntime["loadComponents"];
24
25
  export const loadForms: BetterCMSRuntime["loadForms"];
25
26
  const runtime: BetterCMSRuntime;
26
27
  export default runtime;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@bettercms-ai/astro",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
- "description": "The BetterCMS adapter for Astro \u2014 a `bettercms()` integration, a `bettercms:client` virtual module, typed content loaders, draft preview, and native .astro rendering components.",
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",
7
7
  "module": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
@@ -31,6 +31,7 @@
31
31
  "./draft-enable": "./dist/routes/draft-enable.js",
32
32
  "./draft-disable": "./dist/routes/draft-disable.js",
33
33
  "./components/BcmsBlocks.astro": "./components/BcmsBlocks.astro",
34
+ "./components/BcmsLiveBlocks.astro": "./components/BcmsLiveBlocks.astro",
34
35
  "./components/BcmsField.astro": "./components/BcmsField.astro",
35
36
  "./components/BcmsForm.astro": "./components/BcmsForm.astro",
36
37
  "./components/BcmsSearch.astro": "./components/BcmsSearch.astro",
@@ -53,23 +54,31 @@
53
54
  },
54
55
  "dependencies": {
55
56
  "@bettercms-ai/image-url": "^0.3.0",
56
- "@bettercms-ai/sdk": "^1.14.0",
57
+ "@bettercms-ai/sdk": "^1.18.0",
57
58
  "@bettercms-ai/types": "^1.11.0",
58
59
  "@bettercms-ai/ui": "^0.6.0",
59
- "@bettercms-ai/richtext": "^0.1.0"
60
+ "@bettercms-ai/richtext": "^0.2.0",
61
+ "@bettercms-ai/next": "^0.12.0"
60
62
  },
61
63
  "peerDependencies": {
62
- "astro": ">=4"
64
+ "astro": ">=4",
65
+ "@astrojs/preact": ">=4"
63
66
  },
64
67
  "devDependencies": {
65
68
  "@types/node": "^20",
66
69
  "astro": "^5.0.0",
67
70
  "tsup": "^8.5.1",
68
71
  "typescript": "^5",
69
- "vitest": "^4.1.4"
72
+ "vitest": "^4.1.4",
73
+ "@types/react": "^19.0.0"
70
74
  },
71
75
  "publishConfig": {
72
76
  "registry": "https://registry.npmjs.org/",
73
77
  "access": "public"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "@astrojs/preact": {
81
+ "optional": true
82
+ }
74
83
  }
75
84
  }