@bettercms-ai/next 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -0
- package/dist/bettercms-snapshot.js +292 -0
- package/dist/bettercms-snapshot.js.map +1 -0
- package/dist/blocks.d.ts +36 -0
- package/dist/blocks.js +496 -0
- package/dist/blocks.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +203 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +516 -0
- package/dist/index.js +583 -0
- package/dist/index.js.map +1 -0
- package/dist/live.d.ts +15 -0
- package/dist/live.js +27 -0
- package/dist/live.js.map +1 -0
- package/dist/search.d.ts +32 -0
- package/dist/search.js +111 -0
- package/dist/search.js.map +1 -0
- package/dist/visual-editing.d.ts +7 -0
- package/dist/visual-editing.js +35 -0
- package/dist/visual-editing.js.map +1 -0
- package/package.json +86 -0
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# @bettercms-ai/next
|
|
2
|
+
|
|
3
|
+
The BetterCMS adapter for **Next.js** (App Router). Typed, cache-aware content reads,
|
|
4
|
+
draft preview, and `llms.txt` generation — the deterministic floor under your CMS.
|
|
5
|
+
|
|
6
|
+
Pairs with [`@bettercms-ai/codegen`](../codegen): generate `BetterCMSSchema` from your
|
|
7
|
+
content models, then get fully-typed `getEntry`/`listEntries` with autocomplete.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @bettercms-ai/next
|
|
13
|
+
# peers: next >= 14, react >= 18
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// lib/cms.ts
|
|
20
|
+
import { createBetterCMS } from "@bettercms-ai/next";
|
|
21
|
+
import type { BetterCMSSchema } from "./bettercms.generated"; // from @bettercms-ai/codegen
|
|
22
|
+
|
|
23
|
+
export const cms = createBetterCMS<BetterCMSSchema>({
|
|
24
|
+
workspace: "acme",
|
|
25
|
+
apiKey: process.env.BETTERCMS_API_KEY,
|
|
26
|
+
// baseUrl defaults to https://api.bettercms.ai/api/v1/delivery
|
|
27
|
+
revalidate: 60, // default ISR window (seconds); false = always fresh
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
// app/blog/[slug]/page.tsx
|
|
33
|
+
import { cms } from "@/lib/cms";
|
|
34
|
+
import { notFound } from "next/navigation";
|
|
35
|
+
|
|
36
|
+
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
|
|
37
|
+
const { slug } = await params;
|
|
38
|
+
const entry = await cms.getEntry<{ title: string; body: string }>(slug, {
|
|
39
|
+
revalidate: 300,
|
|
40
|
+
tags: [`post:${slug}`], // invalidate on publish via revalidateTag(`post:${slug}`)
|
|
41
|
+
});
|
|
42
|
+
if (!entry) notFound();
|
|
43
|
+
return <article><h1>{entry.fields.title}</h1>{/* ... */}</article>;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
// app/blog/page.tsx — list, typed by your model registry
|
|
49
|
+
const { items, hasNextPage } = await cms.listEntries("blog", { page: 1, perPage: 20 });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### `createBetterCMS<Schema>(config)`
|
|
55
|
+
- `workspace` — workspace slug (required)
|
|
56
|
+
- `apiKey` — delivery API key (sent as `Authorization: Bearer`)
|
|
57
|
+
- `baseUrl` / `previewBaseUrl` — override the API base
|
|
58
|
+
- `revalidate` — default ISR window (seconds) or `false`
|
|
59
|
+
|
|
60
|
+
### `getEntry<TFields>(slug, opts?)` → `BetterCMSEntry<TFields> | null`
|
|
61
|
+
Single published entry by content slug. `null` on 404.
|
|
62
|
+
Options: `revalidate`, `tags`, `depth` (0–2 ref hydration), `select` (field projection →
|
|
63
|
+
result is `Partial<TFields>`), `preview` + `previewToken`.
|
|
64
|
+
|
|
65
|
+
### `listEntries(model, opts?)` → `EntryList<Fields>`
|
|
66
|
+
Published entries for a model, paginated. **Throws** `BetterCMSError` (CONTENT_NOT_FOUND)
|
|
67
|
+
for an unknown workspace/model; an empty model returns an empty page.
|
|
68
|
+
Options: `page`, `perPage`, `revalidate`, `tags`, `depth`, `select`.
|
|
69
|
+
|
|
70
|
+
### Caching & revalidation
|
|
71
|
+
Reads flow through `fetch`, which Next patches into the data cache. Pass `tags` and call
|
|
72
|
+
`revalidateTag(tag)` on publish. `revalidate: false` **with** tags = cache-until-tag;
|
|
73
|
+
without tags = `no-store` (always fresh).
|
|
74
|
+
|
|
75
|
+
### Draft preview
|
|
76
|
+
```ts
|
|
77
|
+
import { isDraftEnabled } from "@bettercms-ai/next";
|
|
78
|
+
const preview = await isDraftEnabled(); // wraps next/headers draftMode()
|
|
79
|
+
const entry = await cms.getEntry(slug, { preview, previewToken: token });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `llms.txt`
|
|
83
|
+
```ts
|
|
84
|
+
// app/llms.txt/route.ts
|
|
85
|
+
import { llmsTxtRoute } from "@bettercms-ai/next";
|
|
86
|
+
export const GET = llmsTxtRoute({ models, title: "Acme", workspace: "acme" });
|
|
87
|
+
```
|
|
88
|
+
`generateLlmsTxt(models, opts)` (pure) and `fetchModels({ apiUrl, apiKey })` are also exported.
|
|
89
|
+
Author-controlled values are escaped so content can't forge document structure.
|
|
90
|
+
|
|
91
|
+
### Forms
|
|
92
|
+
|
|
93
|
+
Forms ship in your content snapshot (`bcms-content.json` → `forms`, written by the build
|
|
94
|
+
Action). Render one natively with `<BcmsForm>` — real inputs, no iframe, your own styles.
|
|
95
|
+
It handles conditional `showIf` fields, URL-query prefill, the honeypot, an optional
|
|
96
|
+
Turnstile widget, validation errors, and submission. No API key (the endpoint is
|
|
97
|
+
Turnstile-gated).
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// app/contact/page.tsx — a Server Component reads the Action's snapshot...
|
|
101
|
+
import { getForm } from "@bettercms-ai/next";
|
|
102
|
+
import { ContactForm } from "./contact-form";
|
|
103
|
+
|
|
104
|
+
export default function ContactPage() {
|
|
105
|
+
const form = getForm("Contact"); // by name or id, from bcms-content.json
|
|
106
|
+
return form ? <ContactForm form={form} /> : null;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
// ./contact-form.tsx — <BcmsForm> ships from its own "use client" entry.
|
|
112
|
+
import { BcmsForm } from "@bettercms-ai/next/form";
|
|
113
|
+
import type { DeliveryForm } from "@bettercms-ai/sdk";
|
|
114
|
+
|
|
115
|
+
export function ContactForm({ form }: { form: DeliveryForm }) {
|
|
116
|
+
return <BcmsForm form={form} turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Not on React? Submit from anywhere with the framework-agnostic core:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { submitForm } from "@bettercms-ai/sdk";
|
|
124
|
+
await submitForm({ formId, data: { email: "a@b.com" }, turnstileToken });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The dashboard's copy-paste embed snippet remains the zero-build fallback for non-CMS sites.
|
|
128
|
+
|
|
129
|
+
### Page blocks (`<BcmsBlocks>`) — forms placed in the page builder
|
|
130
|
+
|
|
131
|
+
When you build a page in the BetterCMS dashboard and drop a **Form block** onto it, the
|
|
132
|
+
form renders inline exactly where you placed it. Render the page's blocks with
|
|
133
|
+
`<BcmsBlocks>` and pass the forms from `readForms()` — a `form` block resolves its
|
|
134
|
+
`formId` against them and renders `<BcmsForm>` for you. Every other block type
|
|
135
|
+
(heading, text, image, button, spacer, video, columns) renders to plain, class-driven
|
|
136
|
+
markup you can style.
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
// app/[slug]/page.tsx
|
|
140
|
+
import { createBetterCMS, readForms } from "@bettercms-ai/next";
|
|
141
|
+
import { BcmsBlocks } from "@bettercms-ai/next/blocks"; // its own "use client" entry
|
|
142
|
+
|
|
143
|
+
const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
|
|
144
|
+
|
|
145
|
+
export default async function Page({ params }: { params: { slug: string } }) {
|
|
146
|
+
// The page's blocks come from the delivery API (`entry.blocks`); forms from the snapshot.
|
|
147
|
+
const page = await cms.getPage(params.slug);
|
|
148
|
+
const { forms, turnstileSiteKey } = readForms();
|
|
149
|
+
return (
|
|
150
|
+
<BcmsBlocks
|
|
151
|
+
blocks={page.blocks}
|
|
152
|
+
forms={forms}
|
|
153
|
+
turnstileSiteKey={turnstileSiteKey ?? undefined}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## SEO — per-page `<head>` with `generateMetadata`
|
|
160
|
+
|
|
161
|
+
`getPage()` returns the page's `metaTitle`, `metaDescription`, and the rich `metaJson`
|
|
162
|
+
(OG / Twitter / canonical / JSON-LD) edited in the dashboard's SEO panel. Map them into
|
|
163
|
+
the route's `<head>` with `buildMetadata` — per-page values layer over your site defaults
|
|
164
|
+
(the same page-over-site precedence the live `*.bettercms.site` renderer uses):
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
// app/[slug]/page.tsx
|
|
168
|
+
import type { Metadata } from "next";
|
|
169
|
+
import { createBetterCMS, buildMetadata, resolveSeo, type SiteSeoDefaults } from "@bettercms-ai/next";
|
|
170
|
+
|
|
171
|
+
const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
|
|
172
|
+
|
|
173
|
+
// Your project-wide fallbacks (optional). Per-page meta always wins.
|
|
174
|
+
const siteDefaults: SiteSeoDefaults = {
|
|
175
|
+
metaDescription: "Selected work, experience, and contact information.",
|
|
176
|
+
ogImage: "https://example.com/og-default.png",
|
|
177
|
+
twitterHandle: "@acme",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export async function generateMetadata(
|
|
181
|
+
{ params }: { params: { slug: string } },
|
|
182
|
+
): Promise<Metadata> {
|
|
183
|
+
const page = await cms.getPage(params.slug);
|
|
184
|
+
if (!page) return {};
|
|
185
|
+
return buildMetadata(page, siteDefaults);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`buildMetadata` covers `<title>`, description, canonical, Open Graph, and Twitter.
|
|
190
|
+
JSON-LD isn't part of Next's `Metadata`, so emit it from the page component using
|
|
191
|
+
`resolveSeo(...).jsonLd`:
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
export default async function Page({ params }: { params: { slug: string } }) {
|
|
195
|
+
const page = await cms.getPage(params.slug);
|
|
196
|
+
if (!page) return null;
|
|
197
|
+
const { jsonLd } = resolveSeo(page, siteDefaults);
|
|
198
|
+
return (
|
|
199
|
+
<>
|
|
200
|
+
{jsonLd.map((node, i) => (
|
|
201
|
+
<script
|
|
202
|
+
key={i}
|
|
203
|
+
type="application/ld+json"
|
|
204
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
|
|
205
|
+
/>
|
|
206
|
+
))}
|
|
207
|
+
{/* …render page.blocks… */}
|
|
208
|
+
</>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Keeping SEO fresh on publish
|
|
214
|
+
|
|
215
|
+
Reads are ISR-cached (`revalidate`, default 60s), so a SEO edit appears after the window
|
|
216
|
+
elapses. For **instant** refresh on publish, configure the project's **revalidation
|
|
217
|
+
webhook** (Project → Settings) to point at a `createRevalidateRoute` handler and pair
|
|
218
|
+
your reads with `tags` + `revalidateTag`. Without it, edits still propagate — just on the
|
|
219
|
+
ISR interval, not immediately.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
UNLICENSED — internal BetterCMS package.
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/llms-txt.ts
|
|
4
|
+
async function fetchModels(opts) {
|
|
5
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
6
|
+
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
7
|
+
const res = await doFetch(`${base}/management/content/models`, {
|
|
8
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, Accept: "application/json" }
|
|
9
|
+
});
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
throw new Error(`Management API returned ${res.status} ${res.statusText}`);
|
|
12
|
+
}
|
|
13
|
+
const body = await res.json();
|
|
14
|
+
return (body.data ?? []).map((m) => ({
|
|
15
|
+
slug: m.slug,
|
|
16
|
+
name: m.name,
|
|
17
|
+
description: m.description ?? null,
|
|
18
|
+
fields: m.fields ?? []
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/snapshot.ts
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
24
|
+
import { dirname, resolve } from "path";
|
|
25
|
+
|
|
26
|
+
// src/types.ts
|
|
27
|
+
var BetterCMSError = class extends Error {
|
|
28
|
+
status;
|
|
29
|
+
code;
|
|
30
|
+
constructor(message, status, code) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "BetterCMSError";
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// src/client.ts
|
|
39
|
+
var DEFAULT_BASE_URL = "https://api.bettercms.ai/api/v1/delivery";
|
|
40
|
+
var DEFAULT_REVALIDATE = 60;
|
|
41
|
+
function statusToCode(status) {
|
|
42
|
+
if (status === 404) return "CONTENT_NOT_FOUND";
|
|
43
|
+
if (status === 401) return "UNAUTHORIZED";
|
|
44
|
+
if (status === 403) return "FORBIDDEN";
|
|
45
|
+
if (status === 429) return "RATE_LIMITED";
|
|
46
|
+
if (status === 422) return "VALIDATION_ERROR";
|
|
47
|
+
return "INTERNAL_ERROR";
|
|
48
|
+
}
|
|
49
|
+
function mapEntry(raw) {
|
|
50
|
+
return {
|
|
51
|
+
slug: raw.slug,
|
|
52
|
+
status: raw.status,
|
|
53
|
+
fields: raw.data,
|
|
54
|
+
publishedAt: raw.publishedAt ?? null,
|
|
55
|
+
updatedAt: raw.updatedAt ?? null
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function createBetterCMS(config) {
|
|
59
|
+
const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
60
|
+
const previewBaseUrl = (config.previewBaseUrl ?? baseUrl.replace(/\/delivery$/, "/preview")).replace(/\/+$/, "");
|
|
61
|
+
const defaultRevalidate = config.revalidate ?? DEFAULT_REVALIDATE;
|
|
62
|
+
function headers() {
|
|
63
|
+
const h = { Accept: "application/json" };
|
|
64
|
+
if (config.apiKey) h["Authorization"] = `Bearer ${config.apiKey}`;
|
|
65
|
+
return h;
|
|
66
|
+
}
|
|
67
|
+
function cacheInit(revalidate, tags, forceNoStore = false) {
|
|
68
|
+
const rv = revalidate ?? defaultRevalidate;
|
|
69
|
+
if (forceNoStore) return { headers: headers(), cache: "no-store" };
|
|
70
|
+
if (rv === false) {
|
|
71
|
+
return tags?.length ? { headers: headers(), next: { revalidate: false, tags } } : { headers: headers(), cache: "no-store" };
|
|
72
|
+
}
|
|
73
|
+
return { headers: headers(), next: { revalidate: rv, tags } };
|
|
74
|
+
}
|
|
75
|
+
async function requestJSON(url, init, { nullOn404 = false } = {}) {
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await fetch(url, init);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new BetterCMSError(
|
|
81
|
+
`Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
82
|
+
0,
|
|
83
|
+
"NETWORK_ERROR"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (res.status === 404 && nullOn404) return null;
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
let message = res.statusText || "Request failed";
|
|
89
|
+
try {
|
|
90
|
+
const body = await res.json();
|
|
91
|
+
message = body.error ?? body.message ?? message;
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
throw new BetterCMSError(message, res.status, statusToCode(res.status));
|
|
95
|
+
}
|
|
96
|
+
return await res.json();
|
|
97
|
+
}
|
|
98
|
+
function entryQuery(opts) {
|
|
99
|
+
const params = new URLSearchParams();
|
|
100
|
+
if (opts?.depth != null) params.set("depth", String(opts.depth));
|
|
101
|
+
if (opts?.select?.length) params.set("select", opts.select.join(","));
|
|
102
|
+
const qs = params.toString();
|
|
103
|
+
return qs ? `?${qs}` : "";
|
|
104
|
+
}
|
|
105
|
+
const client = {
|
|
106
|
+
async getEntry(slug, opts) {
|
|
107
|
+
const encoded = encodeURIComponent(slug);
|
|
108
|
+
const query = entryQuery(opts);
|
|
109
|
+
if (opts?.preview) {
|
|
110
|
+
if (!opts.previewToken) {
|
|
111
|
+
throw new BetterCMSError(
|
|
112
|
+
"preview: true requires a previewToken",
|
|
113
|
+
400,
|
|
114
|
+
"VALIDATION_ERROR"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const tokenParam = `token=${encodeURIComponent(opts.previewToken)}`;
|
|
118
|
+
const sep = query ? "&" : "?";
|
|
119
|
+
const url2 = `${previewBaseUrl}/${encoded}${query}${sep}${tokenParam}`;
|
|
120
|
+
const body = await requestJSON(
|
|
121
|
+
url2,
|
|
122
|
+
cacheInit(void 0, void 0, true),
|
|
123
|
+
{ nullOn404: true }
|
|
124
|
+
);
|
|
125
|
+
return body ? mapEntry(body.data) : null;
|
|
126
|
+
}
|
|
127
|
+
const url = `${baseUrl}/${config.workspace}/content-entries/${encoded}${query}`;
|
|
128
|
+
const raw = await requestJSON(
|
|
129
|
+
url,
|
|
130
|
+
cacheInit(opts?.revalidate, opts?.tags),
|
|
131
|
+
{ nullOn404: true }
|
|
132
|
+
);
|
|
133
|
+
return raw ? mapEntry(raw) : null;
|
|
134
|
+
},
|
|
135
|
+
async listEntries(model, opts) {
|
|
136
|
+
const params = new URLSearchParams();
|
|
137
|
+
if (model) params.set("model", model);
|
|
138
|
+
if (opts?.page != null) params.set("page", String(opts.page));
|
|
139
|
+
if (opts?.perPage != null) params.set("perPage", String(opts.perPage));
|
|
140
|
+
if (opts?.depth != null) params.set("depth", String(opts.depth));
|
|
141
|
+
if (opts?.select?.length) params.set("select", opts.select.join(","));
|
|
142
|
+
const url = `${baseUrl}/${config.workspace}/content-entries?${params}`;
|
|
143
|
+
const body = await requestJSON(
|
|
144
|
+
url,
|
|
145
|
+
cacheInit(opts?.revalidate, opts?.tags)
|
|
146
|
+
);
|
|
147
|
+
const data = body?.data;
|
|
148
|
+
if (!data) {
|
|
149
|
+
return {
|
|
150
|
+
items: [],
|
|
151
|
+
page: opts?.page ?? 1,
|
|
152
|
+
perPage: opts?.perPage ?? 20,
|
|
153
|
+
totalItems: 0,
|
|
154
|
+
totalPages: 1,
|
|
155
|
+
hasNextPage: false,
|
|
156
|
+
hasPreviousPage: false
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
items: data.items.map((r) => mapEntry(r)),
|
|
161
|
+
page: data.page,
|
|
162
|
+
perPage: data.perPage,
|
|
163
|
+
totalItems: data.totalItems,
|
|
164
|
+
totalPages: data.totalPages,
|
|
165
|
+
hasNextPage: data.hasNextPage,
|
|
166
|
+
hasPreviousPage: data.hasPreviousPage
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
async getPage(slug, opts) {
|
|
170
|
+
const url = `${baseUrl}/${config.workspace}/content/${encodeURIComponent(slug)}`;
|
|
171
|
+
const body = await requestJSON(
|
|
172
|
+
url,
|
|
173
|
+
cacheInit(opts?.revalidate, opts?.tags),
|
|
174
|
+
{ nullOn404: true }
|
|
175
|
+
);
|
|
176
|
+
if (!body) return null;
|
|
177
|
+
const { entry, publishedAt } = body.data;
|
|
178
|
+
return {
|
|
179
|
+
slug: entry.slug,
|
|
180
|
+
title: entry.title,
|
|
181
|
+
metaTitle: entry.metaTitle ?? null,
|
|
182
|
+
metaDescription: entry.metaDescription ?? null,
|
|
183
|
+
metaJson: entry.metaJson ?? null,
|
|
184
|
+
blocks: entry.blocks ?? [],
|
|
185
|
+
publishedAt: publishedAt ?? null,
|
|
186
|
+
updatedAt: entry.updatedAt ?? null
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
return client;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/snapshot.ts
|
|
194
|
+
var SNAPSHOT_VERSION = 1;
|
|
195
|
+
var DEFAULT_SNAPSHOT_FILE = "bcms-content.json";
|
|
196
|
+
function serializeSnapshot(snapshot) {
|
|
197
|
+
return JSON.stringify(snapshot, null, 2);
|
|
198
|
+
}
|
|
199
|
+
function writeSnapshot(path, snapshot) {
|
|
200
|
+
const out = resolve(process.cwd(), path);
|
|
201
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
202
|
+
writeFileSync(out, serializeSnapshot(snapshot), "utf8");
|
|
203
|
+
}
|
|
204
|
+
async function buildSnapshot(opts) {
|
|
205
|
+
const client = opts.client ?? createBetterCMS(opts.config);
|
|
206
|
+
const models = {};
|
|
207
|
+
for (const slug of opts.modelSlugs) {
|
|
208
|
+
const all = [];
|
|
209
|
+
let page = 1;
|
|
210
|
+
for (; ; ) {
|
|
211
|
+
const res = await client.listEntries(slug, { page, perPage: 100, revalidate: false });
|
|
212
|
+
all.push(...res.items);
|
|
213
|
+
if (!res.hasNextPage) break;
|
|
214
|
+
page += 1;
|
|
215
|
+
}
|
|
216
|
+
models[slug] = all;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
version: SNAPSHOT_VERSION,
|
|
220
|
+
workspace: opts.config.workspace,
|
|
221
|
+
generatedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
222
|
+
models
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// bin/bettercms-snapshot.ts
|
|
227
|
+
var DEFAULT_API_URL = "https://api.bettercms.ai/api/v1";
|
|
228
|
+
function parseArgs(argv) {
|
|
229
|
+
const args = {
|
|
230
|
+
apiUrl: process.env.BETTERCMS_API_URL ?? DEFAULT_API_URL,
|
|
231
|
+
apiKey: process.env.BETTERCMS_API_KEY,
|
|
232
|
+
workspace: process.env.BETTERCMS_WORKSPACE,
|
|
233
|
+
out: DEFAULT_SNAPSHOT_FILE,
|
|
234
|
+
help: false
|
|
235
|
+
};
|
|
236
|
+
for (let i = 0; i < argv.length; i++) {
|
|
237
|
+
const arg = argv[i];
|
|
238
|
+
const next = () => argv[++i];
|
|
239
|
+
if (arg === "--help" || arg === "-h") args.help = true;
|
|
240
|
+
else if (arg === "--api-url") args.apiUrl = next();
|
|
241
|
+
else if (arg === "--api-key") args.apiKey = next();
|
|
242
|
+
else if (arg === "--workspace") args.workspace = next();
|
|
243
|
+
else if (arg === "--out" || arg === "-o") args.out = next();
|
|
244
|
+
}
|
|
245
|
+
return args;
|
|
246
|
+
}
|
|
247
|
+
var USAGE = `bettercms-snapshot \u2014 write bcms-content.json for a static build
|
|
248
|
+
|
|
249
|
+
Usage:
|
|
250
|
+
bettercms-snapshot [--out bcms-content.json] [--workspace <slug>] [--api-url <url>] [--api-key <key>]
|
|
251
|
+
|
|
252
|
+
Env:
|
|
253
|
+
BETTERCMS_API_KEY Project key (must read models + entries) [required]
|
|
254
|
+
BETTERCMS_API_URL Management/Delivery API base [default ${DEFAULT_API_URL}]
|
|
255
|
+
BETTERCMS_WORKSPACE Workspace slug for the Delivery path [required]
|
|
256
|
+
`;
|
|
257
|
+
async function main() {
|
|
258
|
+
const args = parseArgs(process.argv.slice(2));
|
|
259
|
+
if (args.help) {
|
|
260
|
+
process.stdout.write(USAGE);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!args.apiKey) {
|
|
264
|
+
process.stderr.write("error: BETTERCMS_API_KEY (or --api-key) is required\n");
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
if (!args.workspace) {
|
|
268
|
+
process.stderr.write("error: BETTERCMS_WORKSPACE (or --workspace) is required\n");
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
const models = await fetchModels({ apiUrl: args.apiUrl, apiKey: args.apiKey });
|
|
272
|
+
const snapshot = await buildSnapshot({
|
|
273
|
+
config: {
|
|
274
|
+
workspace: args.workspace,
|
|
275
|
+
apiKey: args.apiKey,
|
|
276
|
+
baseUrl: `${args.apiUrl.replace(/\/+$/, "")}/delivery`
|
|
277
|
+
},
|
|
278
|
+
modelSlugs: models.map((m) => m.slug)
|
|
279
|
+
});
|
|
280
|
+
writeSnapshot(args.out, snapshot);
|
|
281
|
+
const count = Object.values(snapshot.models).reduce((n, e) => n + e.length, 0);
|
|
282
|
+
process.stdout.write(
|
|
283
|
+
`bettercms-snapshot: wrote ${args.out} (${models.length} models, ${count} entries)
|
|
284
|
+
`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
main().catch((err) => {
|
|
288
|
+
process.stderr.write(`bettercms-snapshot failed: ${err instanceof Error ? err.message : String(err)}
|
|
289
|
+
`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
});
|
|
292
|
+
//# sourceMappingURL=bettercms-snapshot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/llms-txt.ts","../src/snapshot.ts","../src/types.ts","../src/client.ts","../bin/bettercms-snapshot.ts"],"sourcesContent":["/**\n * llms.txt generation — the AI-discovery surface of the deterministic floor.\n *\n * `generateLlmsTxt` is pure (models in → string out), so it is trivially testable and\n * deterministic. `llmsTxtRoute` wraps it as an App Router `route.ts` handler. `fetchModels`\n * pulls the live schema from the Management API for sites that want a self-updating file.\n *\n * Output follows the llmstxt.org convention: an H1 title, a `>` summary, then sections.\n */\n\n/** Minimal field shape needed to describe a model in llms.txt. */\nexport interface LlmsModelField {\n key: string;\n label?: string;\n type: string;\n required?: boolean;\n}\n\n/** Minimal model shape — a subset of the Management API model row. */\nexport interface LlmsModel {\n slug: string;\n name?: string;\n description?: string | null;\n fields: LlmsModelField[];\n}\n\nexport interface LlmsTxtOptions {\n /** H1 title. Default: \"BetterCMS content\". */\n title?: string;\n /** One-line `>` summary under the title. */\n description?: string;\n /** Delivery API base used to render example endpoints. */\n baseUrl?: string;\n /** Workspace slug used in example endpoints. */\n workspace?: string;\n /** Extra free-form lines appended under a \"## Notes\" section. */\n notes?: string[];\n}\n\n// Model name/slug/description and field key/label/type are author/agent-controlled\n// (the same content the sibling codegen package treats as hostile via escapeJsDoc).\n// llms.txt is fed to AI crawlers, so unescaped values could forge headings/blockquotes\n// or break out of code spans — a structure-spoofing / prompt-injection vector.\n\n/** Collapse to a single line — kills newline-based block injection. */\nfunction oneLine(text: string): string {\n return text.replace(/[\\r\\n]+/g, \" \").trim();\n}\n\n/** For text rendered OUTSIDE a code span (titles, descriptions, labels): single-line +\n * escape a leading markdown block-control char so it can't start a heading/list/quote. */\nfunction inlineText(text: string): string {\n return oneLine(text).replace(/^([#>\\-*+=|])/, \"\\\\$1\");\n}\n\n/** For text rendered INSIDE a code span: strip backticks (which would close the span)\n * and newlines. */\nfunction codeSpan(text: string): string {\n return oneLine(text).replace(/`/g, \"\");\n}\n\nfunction fieldLine(f: LlmsModelField): string {\n const req = f.required ? \", required\" : \"\";\n const label = f.label && f.label !== f.key ? ` — ${inlineText(f.label)}` : \"\";\n return ` - \\`${codeSpan(f.key)}\\` (${codeSpan(f.type)}${req})${label}`;\n}\n\n/**\n * Render an llms.txt document describing the available content models. Deterministic:\n * models are sorted by slug; field order is preserved as authored.\n */\nexport function generateLlmsTxt(models: LlmsModel[], opts: LlmsTxtOptions = {}): string {\n const title = opts.title ?? \"BetterCMS content\";\n const sorted = [...models].sort((a, b) =>\n a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0,\n );\n\n const lines: string[] = [`# ${inlineText(title)}`, \"\"];\n if (opts.description) {\n lines.push(`> ${inlineText(opts.description)}`, \"\");\n }\n\n const base =\n opts.baseUrl && opts.workspace\n ? `${opts.baseUrl.replace(/\\/+$/, \"\")}/${opts.workspace}/content-entries`\n : undefined;\n\n lines.push(\"## Content models\", \"\");\n if (sorted.length === 0) {\n lines.push(\"_No content models are defined yet._\", \"\");\n }\n for (const model of sorted) {\n const name = model.name ?? model.slug;\n lines.push(`### ${inlineText(name)} (\\`${codeSpan(model.slug)}\\`)`);\n if (model.description) lines.push(\"\", inlineText(model.description));\n if (base) {\n lines.push(\"\", `- List: \\`GET ${base}?model=${codeSpan(model.slug)}\\``);\n lines.push(`- Entry: \\`GET ${base}/{slug}\\``);\n }\n if (model.fields.length) {\n lines.push(\"\", \"Fields:\");\n for (const f of model.fields) lines.push(fieldLine(f));\n }\n lines.push(\"\");\n }\n\n if (opts.notes?.length) {\n lines.push(\"## Notes\", \"\");\n for (const note of opts.notes) lines.push(`- ${inlineText(note)}`);\n lines.push(\"\");\n }\n\n return lines.join(\"\\n\");\n}\n\n// ── Integration SKILL.md ────────────────────────────────────────────────────────\n// A framework-agnostic agent skill (frontmatter + lean body) that teaches an AI how\n// to READ this project's content from the Delivery API. Unlike llms.txt it does NOT\n// dump every field — it points at the live types endpoint so the schema can't go\n// stale, and names content types by slug only. Drop at .claude/skills/<name>/SKILL.md.\n\n/** A content type named in the skill — slug + kind only (no fields). */\nexport interface SkillContentType {\n kind: \"page\" | \"model\";\n slug: string;\n /** \"singleton\" | \"dynamic\" for pages; omitted for models. */\n pageType?: string | null;\n}\n\nexport interface SkillOptions {\n /** Human project name (skill title + description). */\n projectName: string;\n /** Project slug → the skill's kebab-case `name:`. */\n projectSlug: string;\n /** Delivery + management API base, e.g. https://api.bettercms.ai/api/v1. */\n apiUrl: string;\n /** Workspace slug used in the delivery paths. */\n workspace: string;\n /** Where to mint a content:read delivery key. */\n mintUrl: string;\n /** Key-authed live TypeScript types endpoint. */\n typesUrl: string;\n /** This project's content types (pages + models), by slug — no field dump. */\n contentTypes: SkillContentType[];\n}\n\n/**\n * Render a concise, framework-agnostic SKILL.md describing how to read THIS\n * project's content from the BetterCMS Delivery API. Deterministic: content types\n * sorted by slug. Author-controlled strings (project name, slugs, pageType) are\n * injection-escaped, same as {@link generateLlmsTxt}.\n */\nexport type SkillTarget = \"claude\" | \"cursor\" | \"agents\";\n\nexport interface SkillVariant {\n /** The agent/IDE this variant targets. */\n target: SkillTarget;\n /** Human label for a tab/picker. */\n label: string;\n /** Suggested path to save the file, e.g. `.claude/skills/<name>/SKILL.md`. */\n filename: string;\n /** Full file content (target-specific frontmatter + the shared body). */\n content: string;\n}\n\n/** The shared, tool-agnostic instruction body — no frontmatter, no save hint. */\nfunction skillBody(opts: SkillOptions): string {\n const proj = inlineText(opts.projectName);\n const api = opts.apiUrl.replace(/\\/+$/, \"\");\n const deliveryBase = `${api}/delivery/${codeSpan(opts.workspace)}`;\n const sorted = [...opts.contentTypes].sort((a, b) =>\n a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0,\n );\n\n const lines: string[] = [\n `# BetterCMS — ${proj}`,\n \"\",\n `${proj}'s content lives in BetterCMS. Read it through the Delivery API with a project ` +\n \"`content:read` key. Pull field shapes live from the types endpoint below — don't hardcode \" +\n \"them, the schema can change.\",\n \"\",\n \"## Auth\",\n \"Send `X-API-Key: <key>` on every read. Mint a long-lived `content:read` key (reads PUBLISHED \" +\n `content, never expires) at ${opts.mintUrl}. For drafts, mint a \\`content:read:draft\\` key ` +\n \"(force-expired to ≤1h), then publish and switch to the long-lived key.\",\n \"\",\n \"## Content types\",\n ];\n if (sorted.length === 0) {\n lines.push(\"_None yet — create pages or models first (e.g. via the BetterCMS MCP)._\");\n } else {\n for (const ct of sorted) {\n const kind =\n ct.kind === \"page\"\n ? `page${ct.pageType ? ` (${codeSpan(ct.pageType)})` : \"\"}`\n : \"model\";\n lines.push(`- \\`${codeSpan(ct.slug)}\\` — ${kind}`);\n }\n }\n lines.push(\n \"\",\n `## Reading (Delivery API · base \\`${deliveryBase}\\`)`,\n \"- Page: `GET .../content/{slug}`\",\n \"- Entries (list): `GET .../content-entries?model={slug}`\",\n \"- Entry: `GET .../content-entries/{slug}`\",\n \"\",\n \"## Typed client\",\n `- Live TypeScript types: \\`GET ${opts.typesUrl}\\` — authoritative field shapes; generate/refresh from here.`,\n \"- `npm i @bettercms-ai/next @bettercms-ai/codegen`, then construct the client. \" +\n \"`baseUrl` + `workspace` are required — there is no `apiUrl`/`endpoint` option:\",\n \"\",\n \"```ts\",\n 'import { createBetterCMS } from \"@bettercms-ai/next\";',\n \"const cms = createBetterCMS<BetterCMSSchema>({\",\n ` baseUrl: \"${api}/delivery\",`,\n ` workspace: \"${codeSpan(opts.workspace)}\",`,\n \" apiKey: process.env.BETTERCMS_API_KEY,\",\n \"});\",\n \"```\",\n \"\",\n \"## Field shapes you'll read\",\n \"Canonical READ shapes the Delivery API returns. Per-field types come from the types \" +\n \"endpoint; these container shapes are stable:\",\n '- **richtext** → `{ html, value: { root: … }, format: \"lexical-…\" }`. Render `html`, ' +\n \"or walk `value.root` for structured rendering.\",\n \"- **image** → `{ url, altText?, width?, height?, id? }` — an object, use `.url` (the \" +\n \"write value is a bare CDN URL string, but reads are normalized to this object).\",\n \"- **array + zones** → `{ nonRepeatable?: { <key>: value }, repeatable?: Array<{ <key>: \" +\n \"value }> }`. `nonRepeatable` is one fixed block; `repeatable` is a list — map over it.\",\n '- **array (primitive)** → a flat list (e.g. `string[]`). `type: \"array\"` alone is ' +\n \"ambiguous — the generated type tells you which.\",\n \"\",\n \"Example entry `fields` (a `sections` zone field + a `tags` primitive array):\",\n \"```jsonc\",\n \"{\",\n ' \"sections\": {',\n ' \"nonRepeatable\": { \"eyebrow\": \"New\", \"heading\": \"Welcome\" },',\n ' \"repeatable\": [{ \"label\": \"Docs\", \"url\": \"/docs\", \"icon\": { \"url\": \"https://cdn…\" } }]',\n \" },\",\n ' \"tags\": [\"alpha\", \"beta\"]',\n \"}\",\n \"```\",\n \"\",\n \"## Live Preview bindings (makes the site visually editable)\",\n \"Generate a bindings module alongside the types and spread it so the dashboard's Live \" +\n \"Preview can edit each field in place:\",\n \"```bash\",\n `BETTERCMS_API_KEY=<key> npx bettercms-codegen --api-url ${api} --bindings-out src/bettercms.bindings.generated.ts`,\n \"```\",\n \"```tsx\",\n 'import { bcms } from \"./bettercms.bindings.generated\";',\n \"// scalar: <h1 {...bcms.<model>.<field>}>{entry.fields.field}</h1>\",\n \"// list item: <article {...bcms.<model>.<arr>.$(i)}><h3 {...bcms.<model>.<arr>.<sub>(i)}>{item.sub}</h3></article>\",\n \"// primitive list:<li {...bcms.<model>.<arr>.value(i)}>{tag}</li>\",\n \"```\",\n \"Bindings emit attributes only when the platform builds with `BCMS_ANNOTATE` (preview \" +\n \"builds); production ships nothing extra, so spreading them is always safe.\",\n );\n return lines.join(\"\\n\");\n}\n\nfunction skillDescription(proj: string): string {\n return (\n `Read ${proj}'s content from BetterCMS via the Delivery API — auth, listing and fetching ` +\n \"pages and content entries, pulling live TypeScript types, and the @bettercms-ai/next typed \" +\n \"client. Use whenever fetching, rendering, or typing this project's CMS content.\"\n );\n}\n\n/**\n * Render the integration skill for a specific agent `target`. The instruction body is\n * identical across tools; only the frontmatter (and where you save it) differ:\n * - claude → Claude Code skill (`name` + `description`)\n * - cursor → Cursor `.mdc` rule (`description` + `globs` + `alwaysApply`, \"Agent Requested\")\n * - agents → plain Markdown, no frontmatter (drop into a repo-root `AGENTS.md`)\n */\nexport function generateSkill(opts: SkillOptions, target: SkillTarget = \"claude\"): string {\n const name = `bettercms-${codeSpan(opts.projectSlug)}`;\n const desc = skillDescription(inlineText(opts.projectName));\n const body = skillBody(opts);\n\n let frontmatter = \"\";\n if (target === \"claude\") {\n frontmatter = `---\\nname: ${name}\\ndescription: ${desc}\\n---\\n\\n`;\n } else if (target === \"cursor\") {\n frontmatter = `---\\ndescription: ${desc}\\nglobs:\\nalwaysApply: false\\n---\\n\\n`;\n }\n return `${frontmatter}${body}\\n`;\n}\n\nconst TARGET_META: Record<SkillTarget, { label: string; file: (name: string) => string }> = {\n claude: { label: \"Claude Code\", file: (n) => `.claude/skills/${n}/SKILL.md` },\n cursor: { label: \"Cursor\", file: (n) => `.cursor/rules/${n}.mdc` },\n agents: { label: \"AGENTS.md (generic)\", file: () => \"AGENTS.md\" },\n};\n\n/** Render the skill for every supported agent target — drives a tabbed picker in the UI. */\nexport function generateSkillVariants(opts: SkillOptions): SkillVariant[] {\n const name = `bettercms-${codeSpan(opts.projectSlug)}`;\n return (Object.keys(TARGET_META) as SkillTarget[]).map((target) => ({\n target,\n label: TARGET_META[target].label,\n filename: TARGET_META[target].file(name),\n content: generateSkill(opts, target),\n }));\n}\n\n/** Options for {@link llmsTxtRoute}. Provide one of `models` or `getModels`. */\nexport interface LlmsTxtRouteConfig extends LlmsTxtOptions {\n /** Static model list (e.g. fetched at build time). */\n models?: LlmsModel[];\n /** Dynamic provider — called per request (wrap in your own caching if needed). */\n getModels?: () => Promise<LlmsModel[]> | LlmsModel[];\n /** Cache-Control header value. Default: `public, max-age=3600`. */\n cacheControl?: string;\n}\n\n/**\n * Build an App Router route handler that serves llms.txt as `text/plain`.\n *\n * ```ts\n * // app/llms.txt/route.ts\n * import { llmsTxtRoute } from \"@bettercms-ai/next\";\n * export const GET = llmsTxtRoute({ models, title: \"Acme\", workspace: \"acme\" });\n * ```\n */\nexport function llmsTxtRoute(config: LlmsTxtRouteConfig): () => Promise<Response> {\n const { models, getModels, cacheControl, ...opts } = config;\n return async () => {\n const resolved = getModels ? await getModels() : (models ?? []);\n const body = generateLlmsTxt(resolved, opts);\n return new Response(body, {\n status: 200,\n headers: {\n \"Content-Type\": \"text/plain; charset=utf-8\",\n \"Cache-Control\": cacheControl ?? \"public, max-age=3600\",\n },\n });\n };\n}\n\n/**\n * Fetch content models from the Management API (`GET /management/content/models`).\n * The key is project-scoped server-side, so this returns exactly this site's schema.\n * Dependency-free so it runs in any runtime (Edge route, build script, Node).\n */\nexport async function fetchModels(opts: {\n /** Management API base, e.g. \"https://api.bettercms.ai/api/v1\". */\n apiUrl: string;\n /** A management-scoped key (content:manage). */\n apiKey: string;\n /** Optional fetch override (testing / custom runtime). */\n fetchImpl?: typeof fetch;\n}): Promise<LlmsModel[]> {\n const doFetch = opts.fetchImpl ?? fetch;\n const base = opts.apiUrl.replace(/\\/+$/, \"\");\n const res = await doFetch(`${base}/management/content/models`, {\n headers: { Authorization: `Bearer ${opts.apiKey}`, Accept: \"application/json\" },\n });\n if (!res.ok) {\n throw new Error(`Management API returned ${res.status} ${res.statusText}`);\n }\n const body = (await res.json()) as { data?: LlmsModel[] };\n return (body.data ?? []).map((m) => ({\n slug: m.slug,\n name: m.name,\n description: m.description ?? null,\n fields: m.fields ?? [],\n }));\n}\n","/**\n * Build-time content snapshot — the writer + reader of `bcms-content.json`.\n *\n * Why this exists: a static (`output: \"export\"`) build must resolve all content at\n * build time. If a page instead falls back to a live `cache: \"no-store\"` fetch, its\n * route becomes dynamic and `output: \"export\"` silently drops it — you get an empty\n * `out/`. The fix is a single build-time snapshot the pages read synchronously.\n *\n * The WRITER (`buildSnapshot`/`writeSnapshot`, used by the `bettercms-snapshot` bin)\n * and the READER (`getContent`) share ONE serializer (`serializeSnapshot`/\n * `parseSnapshot`) so the on-disk shape can never drift between the two halves.\n *\n * This is intentionally separate from {@link createBetterCMS}: that stays the live,\n * network-backed client. `getContent` is the snapshot-preferring reader a static\n * site uses. Existing consumers that deliberately rely on the live fallback are\n * unaffected — only `getContent` enforces \"snapshot or bust\".\n */\n\nimport { readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport {\n BetterCMSError,\n type BetterCMSConfig,\n type BetterCMSEntry,\n type EntryList,\n type ListEntriesOptions,\n} from \"./types.js\";\nimport { createBetterCMS } from \"./client.js\";\n\n/** Bump when the on-disk shape changes incompatibly — readers reject mismatches. */\nexport const SNAPSHOT_VERSION = 1;\n/** Default snapshot filename, resolved against `process.cwd()`. */\nexport const DEFAULT_SNAPSHOT_FILE = \"bcms-content.json\";\n\n/** The full-site content snapshot. Entries are grouped by model slug so the reader\n * can serve `listEntries(model)` offline without a per-entry model tag. */\nexport interface ContentSnapshot {\n version: number;\n workspace: string;\n generatedAt: string;\n models: Record<string, BetterCMSEntry<unknown>[]>;\n}\n\n// ── Serializer (shared by writer + reader — single source of on-disk shape) ──────\n\n/** Serialize a snapshot to the canonical JSON form written to disk. */\nexport function serializeSnapshot(snapshot: ContentSnapshot): string {\n return JSON.stringify(snapshot, null, 2);\n}\n\n/** Parse + validate a snapshot JSON string. Throws on a version mismatch so a stale\n * snapshot fails loudly rather than feeding the build the wrong shape. */\nexport function parseSnapshot(json: string): ContentSnapshot {\n const parsed = JSON.parse(json) as ContentSnapshot;\n if (parsed.version !== SNAPSHOT_VERSION) {\n throw new BetterCMSError(\n `Unsupported bcms-content.json version ${parsed.version} (expected ${SNAPSHOT_VERSION}). ` +\n `Regenerate it with \"bettercms-snapshot\".`,\n 0,\n \"SNAPSHOT_VERSION_MISMATCH\",\n );\n }\n return parsed;\n}\n\n// ── Disk I/O ─────────────────────────────────────────────────────────────────────\n\n/** Write a snapshot to `path` (relative paths resolve against cwd), creating dirs. */\nexport function writeSnapshot(path: string, snapshot: ContentSnapshot): void {\n const out = resolve(process.cwd(), path);\n mkdirSync(dirname(out), { recursive: true });\n writeFileSync(out, serializeSnapshot(snapshot), \"utf8\");\n}\n\n/** Read + parse a snapshot from `path`. Returns `null` only when the file is absent;\n * a present-but-corrupt/stale file throws (callers must not treat that as \"missing\"). */\nexport function readSnapshot(path: string): ContentSnapshot | null {\n const out = resolve(process.cwd(), path);\n let json: string;\n try {\n json = readFileSync(out, \"utf8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException)?.code === \"ENOENT\") return null;\n throw err;\n }\n return parseSnapshot(json);\n}\n\n// ── Writer: build a snapshot from the live API ────────────────────────────────────\n\n/** Minimal client surface `buildSnapshot` needs — lets tests inject a fake. Kept\n * non-generic so a simple fake (and the live client's matching overload) both fit. */\ntype ListOnly = {\n listEntries(model?: string, opts?: ListEntriesOptions): Promise<EntryList<unknown>>;\n};\n\n/**\n * Page through every model's published entries and assemble a full-site snapshot.\n * `modelSlugs` come from the Management API (`fetchModels`) so the snapshot covers\n * exactly this project's schema. Pass `client` in tests; defaults to the live client.\n */\nexport async function buildSnapshot(opts: {\n config: BetterCMSConfig;\n modelSlugs: string[];\n client?: ListOnly;\n /** Stamped into `generatedAt`; injectable for deterministic tests. */\n now?: string;\n}): Promise<ContentSnapshot> {\n const client = opts.client ?? createBetterCMS(opts.config);\n const models: Record<string, BetterCMSEntry<unknown>[]> = {};\n for (const slug of opts.modelSlugs) {\n const all: BetterCMSEntry<unknown>[] = [];\n let page = 1;\n for (;;) {\n // revalidate:false → bypass any data cache; this is a one-shot build fetch.\n const res = await client.listEntries(slug, { page, perPage: 100, revalidate: false });\n all.push(...res.items);\n if (!res.hasNextPage) break;\n page += 1;\n }\n models[slug] = all;\n }\n return {\n version: SNAPSHOT_VERSION,\n workspace: opts.config.workspace,\n generatedAt: opts.now ?? new Date().toISOString(),\n models,\n };\n}\n\n// ── Reader: getContent (snapshot-preferring, loud on absence) ─────────────────────\n\n/** Snapshot-backed content reader. Async so it can transparently fall back to the\n * live client in preview without changing the call sites. */\nexport interface ContentSource {\n getEntry<TFields = unknown>(slug: string): Promise<BetterCMSEntry<TFields> | null>;\n listEntries<TFields = unknown>(model?: string): Promise<EntryList<TFields>>;\n}\n\n/** `BETTERCMS_PREVIEW` truthy → read live (uncached), never the snapshot. */\nfunction isPreview(): boolean {\n const v = (\n globalThis as { process?: { env?: Record<string, string | undefined> } }\n ).process?.env?.BETTERCMS_PREVIEW;\n return v != null && v !== \"\" && v !== \"0\" && v !== \"false\";\n}\n\n/** Parsed snapshots keyed by resolved path — read+parse once per build process. */\nconst snapshotCache = new Map<string, ContentSnapshot>();\n\n/** Test-only: clear the module-scope snapshot cache between cases. */\nexport function __resetSnapshotCacheForTests(): void {\n snapshotCache.clear();\n}\n\nfunction emptyPage<TFields>(items: BetterCMSEntry<TFields>[]): EntryList<TFields> {\n return {\n items,\n page: 1,\n perPage: items.length,\n totalItems: items.length,\n totalPages: 1,\n hasNextPage: false,\n hasPreviousPage: false,\n };\n}\n\n/**\n * The reader a static site should use. In a normal (export) build it reads the\n * memoized `bcms-content.json` and serves entries from memory. If the snapshot is\n * absent it THROWS a clear, actionable error — the whole point is to turn the old\n * silent empty-`out/` failure into a loud build error. Set `BETTERCMS_PREVIEW=1`\n * for a preview build that reads live instead.\n */\nexport function getContent<\n Schema extends Record<string, unknown> = Record<string, unknown>,\n>(config: BetterCMSConfig, opts: { snapshotPath?: string } = {}): ContentSource {\n const path = opts.snapshotPath ?? DEFAULT_SNAPSHOT_FILE;\n\n if (isPreview()) {\n const live = createBetterCMS<Schema>(config);\n return {\n getEntry: (slug) => live.getEntry(slug, { revalidate: false }),\n listEntries: (model) => live.listEntries(model, { revalidate: false }),\n };\n }\n\n const resolved = resolve(process.cwd(), path);\n function load(): ContentSnapshot {\n const cached = snapshotCache.get(resolved);\n if (cached) return cached;\n const snap = readSnapshot(path);\n if (!snap) {\n throw new BetterCMSError(\n `BetterCMS content snapshot \"${path}\" not found. A static (output: \"export\") build reads ` +\n `content from this snapshot; without it pages fall back to a live no-store fetch, become ` +\n `dynamic routes, and output: \"export\" silently drops them (empty out/). Run ` +\n `\"bettercms-snapshot\" before \"next build\" (make it your build:preview prestep), or set ` +\n `BETTERCMS_PREVIEW=1 to read live in a preview build.`,\n 0,\n \"SNAPSHOT_MISSING\",\n );\n }\n snapshotCache.set(resolved, snap);\n return snap;\n }\n\n return {\n async getEntry<TFields = unknown>(slug: string) {\n const snap = load();\n for (const entries of Object.values(snap.models)) {\n const found = entries.find((e) => e.slug === slug);\n if (found) return found as BetterCMSEntry<TFields>;\n }\n return null;\n },\n async listEntries<TFields = unknown>(model?: string) {\n const snap = load();\n const items = (\n model ? (snap.models[model] ?? []) : Object.values(snap.models).flat()\n ) as BetterCMSEntry<TFields>[];\n return emptyPage(items);\n },\n };\n}\n","/**\n * Public types for @bettercms-ai/next.\n *\n * `BetterCMSEntry` mirrors the envelope emitted by @bettercms-ai/codegen's generated\n * file. The adapter normalizes the raw Delivery API response into exactly this shape,\n * so `getEntry`/`listEntries` line up 1:1 with your generated model types.\n */\n\n/** Normalized error thrown by adapter reads. Mirrors the SDK's BetterCMSError, but kept\n * local so the adapter carries no heavy runtime dependency into a consumer's bundle. */\nexport class BetterCMSError extends Error {\n readonly status: number;\n readonly code: string;\n\n constructor(message: string, status: number, code: string) {\n super(message);\n this.name = \"BetterCMSError\";\n this.status = status;\n this.code = code;\n }\n}\n\n/**\n * Delivery envelope around a model's typed `fields`. Returned by `getEntry`/`listEntries`.\n * Identical to the `BetterCMSEntry<TFields>` your generated types declare.\n *\n * `publishedAt`/`updatedAt` are ISO-8601 strings, or `null` when absent — never a\n * fabricated empty string (so `new Date(...)` is safe to attempt only on non-null).\n */\nexport interface BetterCMSEntry<TFields> {\n readonly slug: string;\n readonly status: \"draft\" | \"published\";\n readonly fields: TFields;\n readonly publishedAt: string | null;\n readonly updatedAt: string | null;\n}\n\nexport interface BetterCMSConfig {\n /** Workspace slug — the `:workspace` segment of the Delivery API path. */\n workspace: string;\n /** Optional delivery API key (sent as `Authorization: Bearer`). */\n apiKey?: string;\n /** Delivery API base. Default: `https://api.bettercms.ai/api/v1/delivery`. */\n baseUrl?: string;\n /** Preview API base. Default: derived from `baseUrl` (`/delivery` → `/preview`). */\n previewBaseUrl?: string;\n /**\n * Default Next.js revalidation for reads (seconds). `false` = always fresh\n * (`cache: \"no-store\"`). Per-call `revalidate` overrides this. Default: `60`.\n */\n revalidate?: number | false;\n}\n\n/** Per-read options shared by `getEntry` and `listEntries`. */\nexport interface ReadOptions {\n /** ISR window in seconds, or `false` for `cache: \"no-store\"`. Overrides config default. */\n revalidate?: number | false;\n /** Next.js cache tags for on-demand `revalidateTag()` invalidation. */\n tags?: string[];\n /** Reference hydration depth (0 = none, 1 = direct refs, 2 = nested). */\n depth?: 0 | 1 | 2;\n /** Field projection — only these field keys are returned. */\n select?: string[];\n}\n\n/** Options for `getEntry`, including draft-preview access. */\nexport interface GetEntryOptions extends ReadOptions {\n /** Fetch draft content via the preview endpoint. Requires `previewToken`. */\n preview?: boolean;\n /** Signed preview token (from `POST .../preview-token`). */\n previewToken?: string;\n}\n\n/** Options for `listEntries`. */\nexport interface ListEntriesOptions extends ReadOptions {\n /** 1-based page number. Default: 1. */\n page?: number;\n /** Items per page (max 100). Default: 20. */\n perPage?: number;\n}\n\n/** Paginated result returned by `listEntries`. */\nexport interface EntryList<TFields> {\n items: BetterCMSEntry<TFields>[];\n page: number;\n perPage: number;\n totalItems: number;\n totalPages: number;\n hasNextPage: boolean;\n hasPreviousPage: boolean;\n}\n","/**\n * createBetterCMS — the Next.js delivery client.\n *\n * Reads run through the standard `fetch`, which the Next.js App Router patches to\n * participate in the data cache. We pass `next: { revalidate, tags }` so content is\n * cached/ISR'd and can be invalidated on publish via `revalidateTag()`.\n *\n * The raw Delivery API response is normalized into `BetterCMSEntry<TFields>` so the\n * return types match what @bettercms-ai/codegen generates.\n */\n\nimport type { ContentBlock, PageMetaJson } from \"@bettercms-ai/types\";\nimport {\n BetterCMSError,\n type BetterCMSConfig,\n type BetterCMSEntry,\n type EntryList,\n type GetEntryOptions,\n type ListEntriesOptions,\n} from \"./types.js\";\n\n/** A published page with its block_json — feed `blocks` to `<BcmsBlocks>`.\n * The SEO fields (FLO-302) feed `buildMetadata` for the route's `generateMetadata`. */\nexport interface BetterCMSPage {\n slug: string;\n title: string;\n metaTitle: string | null;\n metaDescription: string | null;\n /** Rich SEO (OG / Twitter / canonical / JSON-LD). `null` when unset. */\n metaJson: PageMetaJson | null;\n blocks: ContentBlock[];\n publishedAt: string | null;\n updatedAt: string | null;\n}\n\n/** Raw single-page shape returned by the Delivery API (`/content/:slug`). */\ninterface RawPageResponse {\n data: {\n slug: string;\n entry: {\n slug: string;\n title: string;\n metaTitle?: string | null;\n metaDescription?: string | null;\n metaJson?: PageMetaJson | null;\n blocks?: ContentBlock[];\n updatedAt?: string | null;\n };\n publishedAt: string | null;\n };\n}\n\nconst DEFAULT_BASE_URL = \"https://api.bettercms.ai/api/v1/delivery\";\nconst DEFAULT_REVALIDATE = 60;\n\n/** `fetch`'s init augmented with Next.js's `next` cache directives. (`cache` is declared\n * explicitly because this package compiles without the DOM lib.) */\ntype NextRequestInit = RequestInit & {\n cache?: \"default\" | \"no-store\" | \"force-cache\";\n next?: { revalidate?: number | false; tags?: string[] };\n};\n\n/** Raw single-entry shape returned by the Delivery API. */\ninterface RawEntry {\n id: string;\n slug: string;\n status: \"draft\" | \"published\";\n publishedAt: string | null;\n updatedAt?: string | null;\n data: Record<string, unknown>;\n}\n\ninterface RawListResponse {\n data: {\n items: RawEntry[];\n page: number;\n perPage: number;\n totalItems: number;\n totalPages: number;\n hasNextPage: boolean;\n hasPreviousPage: boolean;\n };\n}\n\nfunction statusToCode(status: number): string {\n if (status === 404) return \"CONTENT_NOT_FOUND\";\n if (status === 401) return \"UNAUTHORIZED\";\n if (status === 403) return \"FORBIDDEN\";\n if (status === 429) return \"RATE_LIMITED\";\n if (status === 422) return \"VALIDATION_ERROR\";\n return \"INTERNAL_ERROR\";\n}\n\n/** Normalize a raw delivery entry into the codegen-aligned envelope. `publishedAt`\n * and `updatedAt` are kept distinct and pass through `null` rather than being\n * conflated or fabricated as \"\" (which would parse to an Invalid Date). */\nfunction mapEntry<TFields>(raw: RawEntry): BetterCMSEntry<TFields> {\n return {\n slug: raw.slug,\n status: raw.status,\n fields: raw.data as TFields,\n publishedAt: raw.publishedAt ?? null,\n updatedAt: raw.updatedAt ?? null,\n };\n}\n\n/**\n * The typed BetterCMS reader for Next.js.\n *\n * @typeParam Schema - your generated `BetterCMSSchema` (slug → fields registry). When\n * supplied, `listEntries(\"blog\")` is typed by the model's fields and the model name\n * autocompletes. Defaults to an open record so it also works untyped.\n */\nexport interface BetterCMSNext<Schema extends Record<string, unknown>> {\n /** Fetch one published entry by its content slug. Returns `null` when not found.\n * Passing `select` narrows the result to `Partial<TFields>` (projection drops fields). */\n getEntry<TFields = unknown>(\n slug: string,\n opts: GetEntryOptions & { select: string[] },\n ): Promise<BetterCMSEntry<Partial<TFields>> | null>;\n getEntry<TFields = unknown>(\n slug: string,\n opts?: GetEntryOptions,\n ): Promise<BetterCMSEntry<TFields> | null>;\n\n /** List published entries, filtered to one model. Throws `BetterCMSError`\n * (CONTENT_NOT_FOUND) for an unknown workspace/model — an empty model returns an\n * empty page. Passing `select` narrows fields to `Partial`. */\n listEntries<M extends keyof Schema & string>(\n model: M,\n opts: ListEntriesOptions & { select: string[] },\n ): Promise<EntryList<Partial<Schema[M]>>>;\n listEntries<M extends keyof Schema & string>(\n model: M,\n opts?: ListEntriesOptions,\n ): Promise<EntryList<Schema[M]>>;\n listEntries<TFields = unknown>(\n model?: string,\n opts?: ListEntriesOptions,\n ): Promise<EntryList<TFields>>;\n\n /** Fetch one published page (with its `blocks` for `<BcmsBlocks>`) by slug.\n * Returns `null` when not found. */\n getPage(\n slug: string,\n opts?: { revalidate?: number | false; tags?: string[] },\n ): Promise<BetterCMSPage | null>;\n}\n\nexport function createBetterCMS<\n Schema extends Record<string, unknown> = Record<string, unknown>,\n>(config: BetterCMSConfig): BetterCMSNext<Schema> {\n const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const previewBaseUrl = (\n config.previewBaseUrl ?? baseUrl.replace(/\\/delivery$/, \"/preview\")\n ).replace(/\\/+$/, \"\");\n const defaultRevalidate = config.revalidate ?? DEFAULT_REVALIDATE;\n\n function headers(): Record<string, string> {\n const h: Record<string, string> = { Accept: \"application/json\" };\n if (config.apiKey) h[\"Authorization\"] = `Bearer ${config.apiKey}`;\n return h;\n }\n\n function cacheInit(\n revalidate: number | false | undefined,\n tags: string[] | undefined,\n forceNoStore = false,\n ): NextRequestInit {\n const rv = revalidate ?? defaultRevalidate;\n // Preview is always uncached.\n if (forceNoStore) return { headers: headers(), cache: \"no-store\" };\n if (rv === false) {\n // With tags, \"false\" means cache until `revalidateTag` (the canonical CMS\n // publish-invalidation pattern) — we must NOT drop the tags to no-store, or\n // revalidateTag could never invalidate. Without tags, it means truly fresh.\n return tags?.length\n ? { headers: headers(), next: { revalidate: false, tags } }\n : { headers: headers(), cache: \"no-store\" };\n }\n return { headers: headers(), next: { revalidate: rv, tags } };\n }\n\n async function requestJSON<T>(\n url: string,\n init: NextRequestInit,\n { nullOn404 = false }: { nullOn404?: boolean } = {},\n ): Promise<T | null> {\n let res: Response;\n try {\n res = await fetch(url, init);\n } catch (err) {\n throw new BetterCMSError(\n `Network error: ${err instanceof Error ? err.message : String(err)}`,\n 0,\n \"NETWORK_ERROR\",\n );\n }\n // A single-entry 404 is \"no such entry\" (→ null). A list 404 is a real error\n // (unknown workspace/model) and must surface, not masquerade as an empty list.\n if (res.status === 404 && nullOn404) return null;\n if (!res.ok) {\n let message = res.statusText || \"Request failed\";\n try {\n const body = (await res.json()) as { error?: string; message?: string };\n message = body.error ?? body.message ?? message;\n } catch {\n /* ignore parse errors */\n }\n throw new BetterCMSError(message, res.status, statusToCode(res.status));\n }\n return (await res.json()) as T;\n }\n\n function entryQuery(opts?: { depth?: 0 | 1 | 2; select?: string[] }): string {\n const params = new URLSearchParams();\n if (opts?.depth != null) params.set(\"depth\", String(opts.depth));\n if (opts?.select?.length) params.set(\"select\", opts.select.join(\",\"));\n const qs = params.toString();\n return qs ? `?${qs}` : \"\";\n }\n\n const client = {\n async getEntry<TFields = unknown>(slug: string, opts?: GetEntryOptions) {\n const encoded = encodeURIComponent(slug);\n const query = entryQuery(opts);\n\n // Preview: drafts are only reachable through the signed preview endpoint, and\n // must never be cached (every request re-validates the token server-side).\n if (opts?.preview) {\n if (!opts.previewToken) {\n throw new BetterCMSError(\n \"preview: true requires a previewToken\",\n 400,\n \"VALIDATION_ERROR\",\n );\n }\n const tokenParam = `token=${encodeURIComponent(opts.previewToken)}`;\n const sep = query ? \"&\" : \"?\";\n const url = `${previewBaseUrl}/${encoded}${query}${sep}${tokenParam}`;\n const body = await requestJSON<{ data: RawEntry }>(\n url,\n cacheInit(undefined, undefined, true),\n { nullOn404: true },\n );\n return body ? mapEntry<TFields>(body.data) : null;\n }\n\n const url = `${baseUrl}/${config.workspace}/content-entries/${encoded}${query}`;\n const raw = await requestJSON<RawEntry>(\n url,\n cacheInit(opts?.revalidate, opts?.tags),\n { nullOn404: true },\n );\n return raw ? mapEntry<TFields>(raw) : null;\n },\n\n async listEntries<TFields = unknown>(\n model?: string,\n opts?: ListEntriesOptions,\n ): Promise<EntryList<TFields>> {\n const params = new URLSearchParams();\n if (model) params.set(\"model\", model);\n if (opts?.page != null) params.set(\"page\", String(opts.page));\n if (opts?.perPage != null) params.set(\"perPage\", String(opts.perPage));\n if (opts?.depth != null) params.set(\"depth\", String(opts.depth));\n if (opts?.select?.length) params.set(\"select\", opts.select.join(\",\"));\n\n const url = `${baseUrl}/${config.workspace}/content-entries?${params}`;\n const body = await requestJSON<RawListResponse>(\n url,\n cacheInit(opts?.revalidate, opts?.tags),\n );\n\n const data = body?.data;\n if (!data) {\n return {\n items: [],\n page: opts?.page ?? 1,\n perPage: opts?.perPage ?? 20,\n totalItems: 0,\n totalPages: 1,\n hasNextPage: false,\n hasPreviousPage: false,\n };\n }\n return {\n items: data.items.map((r) => mapEntry<TFields>(r)),\n page: data.page,\n perPage: data.perPage,\n totalItems: data.totalItems,\n totalPages: data.totalPages,\n hasNextPage: data.hasNextPage,\n hasPreviousPage: data.hasPreviousPage,\n };\n },\n\n async getPage(\n slug: string,\n opts?: { revalidate?: number | false; tags?: string[] },\n ): Promise<BetterCMSPage | null> {\n const url = `${baseUrl}/${config.workspace}/content/${encodeURIComponent(slug)}`;\n const body = await requestJSON<RawPageResponse>(\n url,\n cacheInit(opts?.revalidate, opts?.tags),\n { nullOn404: true },\n );\n if (!body) return null;\n const { entry, publishedAt } = body.data;\n return {\n slug: entry.slug,\n title: entry.title,\n metaTitle: entry.metaTitle ?? null,\n metaDescription: entry.metaDescription ?? null,\n metaJson: entry.metaJson ?? null,\n blocks: entry.blocks ?? [],\n publishedAt: publishedAt ?? null,\n updatedAt: entry.updatedAt ?? null,\n };\n },\n };\n\n // The concrete impls use single generic signatures; the public interface exposes\n // richer overloads (select→Partial, model-keyed typing). Cast once here.\n return client as unknown as BetterCMSNext<Schema>;\n}\n","/**\n * `bettercms-snapshot` — fetch all of a project's content and write `bcms-content.json`.\n *\n * Run this BEFORE `next build` in a static (`output: \"export\"`) build so pages read\n * content from the snapshot instead of falling back to a live no-store fetch (which\n * would make routes dynamic and yield an empty `out/`). Mirror your CI: make it the\n * prestep of `build:preview`, e.g. \"build:preview\": \"bettercms-snapshot && next build\".\n *\n * Auth + endpoint come from flags or env (BETTERCMS_API_KEY, BETTERCMS_API_URL).\n * The key must be able to read both the model list (Management) and entries (Delivery).\n */\n\nimport { fetchModels } from \"../src/llms-txt.js\";\nimport { buildSnapshot, writeSnapshot, DEFAULT_SNAPSHOT_FILE } from \"../src/snapshot.js\";\n\nconst DEFAULT_API_URL = \"https://api.bettercms.ai/api/v1\";\n\ninterface CliArgs {\n apiUrl: string;\n apiKey: string | undefined;\n workspace: string | undefined;\n out: string;\n help: boolean;\n}\n\nfunction parseArgs(argv: string[]): CliArgs {\n const args: CliArgs = {\n apiUrl: process.env.BETTERCMS_API_URL ?? DEFAULT_API_URL,\n apiKey: process.env.BETTERCMS_API_KEY,\n workspace: process.env.BETTERCMS_WORKSPACE,\n out: DEFAULT_SNAPSHOT_FILE,\n help: false,\n };\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i];\n const next = () => argv[++i];\n if (arg === \"--help\" || arg === \"-h\") args.help = true;\n else if (arg === \"--api-url\") args.apiUrl = next();\n else if (arg === \"--api-key\") args.apiKey = next();\n else if (arg === \"--workspace\") args.workspace = next();\n else if (arg === \"--out\" || arg === \"-o\") args.out = next();\n }\n return args;\n}\n\nconst USAGE = `bettercms-snapshot — write bcms-content.json for a static build\n\nUsage:\n bettercms-snapshot [--out bcms-content.json] [--workspace <slug>] [--api-url <url>] [--api-key <key>]\n\nEnv:\n BETTERCMS_API_KEY Project key (must read models + entries) [required]\n BETTERCMS_API_URL Management/Delivery API base [default ${DEFAULT_API_URL}]\n BETTERCMS_WORKSPACE Workspace slug for the Delivery path [required]\n`;\n\nasync function main(): Promise<void> {\n const args = parseArgs(process.argv.slice(2));\n if (args.help) {\n process.stdout.write(USAGE);\n return;\n }\n if (!args.apiKey) {\n process.stderr.write(\"error: BETTERCMS_API_KEY (or --api-key) is required\\n\");\n process.exit(1);\n }\n if (!args.workspace) {\n process.stderr.write(\"error: BETTERCMS_WORKSPACE (or --workspace) is required\\n\");\n process.exit(1);\n }\n\n const models = await fetchModels({ apiUrl: args.apiUrl, apiKey: args.apiKey });\n const snapshot = await buildSnapshot({\n config: {\n workspace: args.workspace,\n apiKey: args.apiKey,\n baseUrl: `${args.apiUrl.replace(/\\/+$/, \"\")}/delivery`,\n },\n modelSlugs: models.map((m) => m.slug),\n });\n\n writeSnapshot(args.out, snapshot);\n const count = Object.values(snapshot.models).reduce((n, e) => n + e.length, 0);\n process.stdout.write(\n `bettercms-snapshot: wrote ${args.out} (${models.length} models, ${count} entries)\\n`,\n );\n}\n\nmain().catch((err: unknown) => {\n process.stderr.write(`bettercms-snapshot failed: ${err instanceof Error ? err.message : String(err)}\\n`);\n process.exit(1);\n});\n"],"mappings":"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","names":["url"]}
|