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